alignfirst 0.6.0 → 0.7.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
@@ -96,6 +96,7 @@ Use your alignfirst-setup-guide skill. Set up AlignFirst in this project without
96
96
  - `docmap` — Browse project documentation.
97
97
  - `conventions` — Print the effective project conventions.
98
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.
99
100
  - `config` — Report the effective project configuration, the companion directory and the location of each AlignFirst file.
100
101
  - `doctor` — Diagnose an AlignFirst setup.
101
102
 
@@ -107,7 +108,7 @@ Run `alignfirst --help` for command usage or `alignfirst guide` to choose a prot
107
108
 
108
109
  ## Companion directories
109
110
 
110
- A companion directory holds a project's AlignFirst files outside its repository, so the repository stays untouched. `~/.alignfirst/companions.json` declares which projects have one:
111
+ A companion directory holds a project's AlignFirst files outside its repository, so the repository stays untouched. The companion registry, `~/.alignfirst/companions/registry.json`, declares which projects have one:
111
112
 
112
113
  ```json
113
114
  {
@@ -121,13 +122,15 @@ A companion directory holds a project's AlignFirst files outside its repository,
121
122
 
122
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"`.
123
124
 
124
- An absent file means no project has a companion. An invalid file makes every command fail; `doctor` reports it and continues.
125
+ An absent registry means no project has a companion. An invalid registry makes every command fail; `doctor` reports it and continues.
126
+
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.
125
128
 
126
129
  ### Matching
127
130
 
128
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.
129
132
 
130
- 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 elsewhere, make `~/.alignfirst/companions` a symlink.
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.
131
134
 
132
135
  ### Resolution
133
136
 
package/dist/cli.js CHANGED
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { CliError } from "./cli-error.js";
4
4
  import { resolveCommandForm } from "./command-form.js";
5
+ import { runCompanion } from "./commands/companion.js";
5
6
  import { runConfig } from "./commands/config.js";
6
7
  import { runContext } from "./commands/context.js";
7
8
  import { runConventions } from "./commands/conventions.js";
@@ -67,6 +68,7 @@ Usage:
67
68
  ${ctx.form} docmap [<arguments>]
68
69
  ${ctx.form} conventions
69
70
  ${ctx.form} context
71
+ ${ctx.form} companion add
70
72
  ${ctx.form} config [--json]
71
73
  ${ctx.form} doctor
72
74
  ${ctx.form} --help
@@ -89,6 +91,8 @@ function dispatch(ctx, command, args) {
89
91
  return runConventions(ctx, args);
90
92
  case "context":
91
93
  return runContext(ctx, args);
94
+ case "companion":
95
+ return runCompanion(ctx, args);
92
96
  case "config":
93
97
  return runConfig(ctx, args);
94
98
  case "doctor":
@@ -0,0 +1,2 @@
1
+ import type { CommandContext } from "../context.js";
2
+ export declare function runCompanion(ctx: CommandContext, args: string[]): number;
@@ -0,0 +1,36 @@
1
+ import { CliError } from "../cli-error.js";
2
+ import { parseBareCommandArgs } from "../parse-args.js";
3
+ import { addCompanion } from "../project-layout.js";
4
+ export function runCompanion(ctx, args) {
5
+ const [command, ...rest] = args;
6
+ switch (command) {
7
+ case "add":
8
+ return runAdd(ctx, rest);
9
+ case "--help":
10
+ case "-h":
11
+ ctx.stdout.write(companionUsage(ctx));
12
+ return 0;
13
+ default:
14
+ throw new CliError(`Unknown or missing companion command.\n\n${companionUsage(ctx)}`);
15
+ }
16
+ }
17
+ function companionUsage(ctx) {
18
+ return `Usage:
19
+ ${ctx.form} companion add
20
+ `;
21
+ }
22
+ function runAdd(ctx, args) {
23
+ const usage = `Usage: ${ctx.form} companion add
24
+
25
+ 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.
27
+ `;
28
+ if (parseBareCommandArgs(ctx, args, usage))
29
+ return 0;
30
+ const registration = addCompanion(ctx.cwd, ctx.home);
31
+ ctx.stdout.write(registration.added
32
+ ? `Registered ${registration.key} in ${registration.registry}.\n`
33
+ : `Already registered by ${registration.key} in ${registration.registry}.\n`);
34
+ ctx.stdout.write(`Companion: ${registration.dir}\n`);
35
+ return 0;
36
+ }
@@ -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 { companionsPath, ITEM_NAMES, layoutOf, renderItemLocation, } from "../project-layout.js";
11
+ import { registryPath, 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) {
@@ -67,11 +67,11 @@ function inspectConfig(ctx, resolved) {
67
67
  return lines;
68
68
  }
69
69
  function inspectCompanion(ctx) {
70
- const path = companionsPath(ctx.home);
70
+ const path = registryPath(ctx.home);
71
71
  const layout = layoutOf(ctx);
72
72
  const file = {
73
73
  level: "ok",
74
- text: `companions.json ${existsSync(path) ? "valid" : "absent"} (${path})`,
74
+ text: `registry ${existsSync(path) ? "valid" : "absent"} (${path})`,
75
75
  };
76
76
  if (layout.companion === null)
77
77
  return [file, { level: "ok", text: "none" }];
@@ -23,8 +23,21 @@ export interface ItemLocation {
23
23
  }
24
24
  export declare function layoutOf(ctx: CommandContext): ProjectLayout;
25
25
  export declare function resolveProjectLayout(cwd: string, home: string): ProjectLayout;
26
- export declare function companionsPath(home: string): string;
26
+ export declare function registryPath(home: string): string;
27
27
  /** One line: `<name>: <path> (<in>)`, with `, missing` when absent. */
28
28
  export declare function renderItemLocation(name: ItemName, location: ItemLocation): string;
29
29
  /** The `_aligndev` tree when it is not the resolved `.plans` and exists. */
30
30
  export declare function separateSessionTree(layout: ProjectLayout): string | undefined;
31
+ export interface CompanionRegistration {
32
+ registry: string;
33
+ /** The key added, or the most specific key that already matched. */
34
+ key: string;
35
+ added: boolean;
36
+ /** Absolute. */
37
+ dir: string;
38
+ }
39
+ /**
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.
42
+ */
43
+ export declare function addCompanion(cwd: string, home: string): CompanionRegistration;
@@ -1,4 +1,4 @@
1
- import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, 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";
@@ -12,8 +12,9 @@ export const ITEM_NAMES = [
12
12
  ".plans",
13
13
  "_aligndev",
14
14
  ];
15
- // The directory holding the companions. A symlink there moves them elsewhere.
15
+ // The directory holding the companions and their registry. A symlink there moves them elsewhere.
16
16
  const COMPANIONS_ROOT = "~/.alignfirst/companions";
17
+ const REGISTRY_FILE = "registry.json";
17
18
  const FLAG = "boolean | 'auto'";
18
19
  const flagsSchema = type({
19
20
  "+": "reject",
@@ -24,7 +25,7 @@ const flagsSchema = type({
24
25
  ".plans?": FLAG,
25
26
  "_aligndev?": FLAG,
26
27
  });
27
- const companionsSchema = type({
28
+ const registrySchema = type({
28
29
  "+": "reject",
29
30
  paths: type.Record("string", flagsSchema),
30
31
  });
@@ -37,23 +38,23 @@ export function resolveProjectLayout(cwd, home) {
37
38
  return { companion, locations: resolveLocations(cwd, companion) };
38
39
  }
39
40
  function resolveCompanion(cwd, home) {
40
- const file = readCompanionsFile(home);
41
- if (file === undefined)
41
+ const registry = readRegistry(home);
42
+ if (registry === undefined)
42
43
  return null;
43
44
  const mainWorktree = findMainWorktree(cwd);
44
45
  if (mainWorktree === undefined)
45
46
  return null;
46
47
  const realHome = realOrResolved(home);
47
- const matches = matchingEntries(file, mainWorktree, realHome);
48
+ const matches = matchingEntries(registry, mainWorktree, realHome);
48
49
  if (matches.length === 0)
49
50
  return null;
50
51
  const flags = mergeFlags(matches);
51
- assertValidFlags(file, flags, matches);
52
- const dir = join(normalizePath(COMPANIONS_ROOT, realHome), companionName(mainWorktree, realHome));
52
+ assertValidFlags(registry, flags, matches);
53
+ const dir = companionDir(mainWorktree, realHome);
53
54
  return { dir, exists: pathExists(dir), entries: matches.map((match) => match.key), flags };
54
55
  }
55
- function readCompanionsFile(home) {
56
- const path = companionsPath(home);
56
+ function readRegistry(home) {
57
+ const path = registryPath(home);
57
58
  if (!pathExists(path))
58
59
  return;
59
60
  let value;
@@ -61,20 +62,20 @@ function readCompanionsFile(home) {
61
62
  value = JSON.parse(readFileSync(path, "utf-8"));
62
63
  }
63
64
  catch (error) {
64
- throw invalidCompanions(path, errorMessage(error));
65
+ throw invalidRegistry(path, errorMessage(error));
65
66
  }
66
- const file = companionsSchema(value);
67
+ const file = registrySchema(value);
67
68
  if (file instanceof type.errors)
68
- throw invalidCompanions(path, file.summary.split("\n", 1)[0]);
69
+ throw invalidRegistry(path, file.summary.split("\n", 1)[0]);
69
70
  const badKey = Object.keys(file.paths).find((key) => !isUserPath(key));
70
71
  if (badKey !== undefined)
71
- throw invalidCompanions(path, `paths key must be an absolute path or start with ~/: ${badKey}`);
72
+ throw invalidRegistry(path, `paths key must be an absolute path or start with ~/: ${badKey}`);
72
73
  return { path, paths: file.paths };
73
74
  }
74
- export function companionsPath(home) {
75
- return join(home, ".alignfirst", "companions.json");
75
+ export function registryPath(home) {
76
+ return join(home, ".alignfirst", "companions", REGISTRY_FILE);
76
77
  }
77
- function invalidCompanions(path, detail) {
78
+ function invalidRegistry(path, detail) {
78
79
  return new CliError(`Invalid ${path}: ${detail}`);
79
80
  }
80
81
  function isUserPath(value) {
@@ -95,8 +96,8 @@ function normalizePath(value, realHome) {
95
96
  function realOrResolved(path) {
96
97
  return existsSync(path) ? realpathSync(path) : resolve(path);
97
98
  }
98
- function matchingEntries(file, mainWorktree, realHome) {
99
- return Object.entries(file.paths)
99
+ function matchingEntries(registry, mainWorktree, realHome) {
100
+ return Object.entries(registry.paths)
100
101
  .map(([key, flags]) => ({ key, path: normalizePath(key, realHome), flags }))
101
102
  .filter((entry) => isSameOrInside(mainWorktree, entry.path))
102
103
  .toSorted((left, right) => right.path.length - left.path.length);
@@ -115,11 +116,14 @@ function mergeFlags(matches) {
115
116
  _aligndev: flagOf("_aligndev"),
116
117
  };
117
118
  }
118
- function assertValidFlags(file, flags, matches) {
119
+ function assertValidFlags(registry, flags, matches) {
119
120
  if (flags._aligndev !== true || flags[".plans"] !== "auto")
120
121
  return;
121
122
  const keys = matches.map((match) => match.key).join(", ");
122
- throw invalidCompanions(file.path, `"_aligndev": true requires ".plans" set to true or false (matching keys: ${keys})`);
123
+ throw invalidRegistry(registry.path, `"_aligndev": true requires ".plans" set to true or false (matching keys: ${keys})`);
124
+ }
125
+ function companionDir(mainWorktree, realHome) {
126
+ return join(normalizePath(COMPANIONS_ROOT, realHome), companionName(mainWorktree, realHome));
123
127
  }
124
128
  function companionName(mainWorktree, realHome) {
125
129
  const name = mainWorktree !== realHome && isSameOrInside(mainWorktree, realHome)
@@ -171,3 +175,33 @@ export function separateSessionTree(layout) {
171
175
  return;
172
176
  return sessions.path;
173
177
  }
178
+ /**
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.
181
+ */
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.");
186
+ const realHome = realOrResolved(home);
187
+ 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);
192
+ const dir = companionDir(mainWorktree, realHome);
193
+ mkdirSync(dir, { recursive: true });
194
+ return { registry: registry.path, key, added: match === undefined, dir };
195
+ }
196
+ function userPathOf(path, realHome) {
197
+ if (path === realHome)
198
+ return "~";
199
+ return isSameOrInside(path, realHome) ? `~/${relative(realHome, path)}` : path;
200
+ }
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`;
205
+ writeFileSync(tmpPath, `${JSON.stringify({ paths }, undefined, 2)}\n`);
206
+ renameSync(tmpPath, registry.path);
207
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alignfirst",
3
- "version": "0.6.0",
3
+ "version": "0.7.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.",