alignfirst 0.1.0-beta.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 +31 -0
- package/bin/alignfirst.mjs +3 -0
- package/dist/cli-error.d.ts +2 -0
- package/dist/cli-error.js +2 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +98 -0
- package/dist/command-form.d.ts +3 -0
- package/dist/command-form.js +8 -0
- package/dist/commands/config.d.ts +2 -0
- package/dist/commands/config.js +55 -0
- package/dist/commands/developers.d.ts +2 -0
- package/dist/commands/developers.js +36 -0
- package/dist/commands/docmap.d.ts +2 -0
- package/dist/commands/docmap.js +22 -0
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +167 -0
- package/dist/commands/guide.d.ts +2 -0
- package/dist/commands/guide.js +126 -0
- package/dist/commands/plans.d.ts +2 -0
- package/dist/commands/plans.js +154 -0
- package/dist/commands/setup.d.ts +2 -0
- package/dist/commands/setup.js +254 -0
- package/dist/commands/sync.d.ts +2 -0
- package/dist/commands/sync.js +63 -0
- package/dist/commands/ticket.d.ts +2 -0
- package/dist/commands/ticket.js +119 -0
- package/dist/context.d.ts +15 -0
- package/dist/context.js +1 -0
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +6 -0
- package/dist/executables.d.ts +1 -0
- package/dist/executables.js +24 -0
- package/dist/git.d.ts +5 -0
- package/dist/git.js +52 -0
- package/dist/overlay.d.ts +19 -0
- package/dist/overlay.js +83 -0
- package/dist/parse-args.d.ts +1 -0
- package/dist/parse-args.js +11 -0
- package/dist/plans/archive.d.ts +4 -0
- package/dist/plans/archive.js +68 -0
- package/dist/plans/layout.d.ts +6 -0
- package/dist/plans/layout.js +19 -0
- package/dist/plans/link.d.ts +2 -0
- package/dist/plans/link.js +36 -0
- package/dist/plans/mode.d.ts +9 -0
- package/dist/plans/mode.js +31 -0
- package/dist/plans/ticket.d.ts +21 -0
- package/dist/plans/ticket.js +104 -0
- package/dist/project-config.d.ts +27 -0
- package/dist/project-config.js +79 -0
- package/dist/protocols.d.ts +2 -0
- package/dist/protocols.js +9 -0
- package/dist/skills.d.ts +9 -0
- package/dist/skills.js +54 -0
- package/dist/version-guard.d.ts +8 -0
- package/dist/version-guard.js +24 -0
- package/package.json +48 -0
- package/templates/guide/code-review/correctness-reviewer.md +53 -0
- package/templates/guide/code-review/intent-reviewer.md +22 -0
- package/templates/guide/code-review/module-javascript.md +80 -0
- package/templates/guide/code-review/module-python.md +77 -0
- package/templates/guide/code-review/module-typescript-strict.md +84 -0
- package/templates/guide/code-review/quality-reviewer.md +54 -0
- package/templates/guide/code-review/reviewer-common.md +62 -0
- package/templates/guide/code-review/safety-reviewer.md +66 -0
- package/templates/guide/core.md +54 -0
- package/templates/guide/overview.md +57 -0
- package/templates/guide/protocols/aad.md +85 -0
- package/templates/guide/protocols/catchup.md +13 -0
- package/templates/guide/protocols/description.md +51 -0
- package/templates/guide/protocols/merge.md +65 -0
- package/templates/guide/protocols/plan.md +256 -0
- package/templates/guide/protocols/review.md +114 -0
- package/templates/guide/protocols/spec.md +78 -0
package/dist/git.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { CliError } from "./cli-error.js";
|
|
5
|
+
export function git(dir, ...args) {
|
|
6
|
+
try {
|
|
7
|
+
execFileSync("git", ["-C", dir, ...args], { stdio: "inherit" });
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
throw gitFailure(args);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function gitFailure(args) {
|
|
14
|
+
return new CliError(`git ${args[0]} failed. See the git output above.`);
|
|
15
|
+
}
|
|
16
|
+
export function assertMainWorktreeRoot(cwd) {
|
|
17
|
+
const toplevel = gitOutput(cwd, "rev-parse", "--show-toplevel");
|
|
18
|
+
if (realpathSync(toplevel) !== realpathSync(cwd))
|
|
19
|
+
throw new CliError("Run this command from the repository root.");
|
|
20
|
+
const gitDir = gitOutput(cwd, "rev-parse", "--absolute-git-dir");
|
|
21
|
+
const commonDir = gitOutput(cwd, "rev-parse", "--git-common-dir");
|
|
22
|
+
if (realpathSync(gitDir) !== realpathSync(resolve(cwd, commonDir)))
|
|
23
|
+
throw new CliError("Run this command from the main worktree. Linked worktrees reach .plans through it.");
|
|
24
|
+
}
|
|
25
|
+
export function gitOutput(dir, ...args) {
|
|
26
|
+
try {
|
|
27
|
+
return execFileSync("git", ["-C", dir, ...args], { encoding: "utf-8" }).trim();
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw gitFailure(args);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function gitOutputOrUndefined(dir, ...args) {
|
|
34
|
+
try {
|
|
35
|
+
return execFileSync("git", ["-C", dir, ...args], {
|
|
36
|
+
encoding: "utf-8",
|
|
37
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
38
|
+
}).trim();
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function gitSucceeds(dir, ...args) {
|
|
45
|
+
try {
|
|
46
|
+
execFileSync("git", ["-C", dir, ...args], { stdio: "ignore" });
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type ProjectConfig } from "./project-config.js";
|
|
2
|
+
export interface Overlay {
|
|
3
|
+
dir: string;
|
|
4
|
+
config: ProjectConfig;
|
|
5
|
+
matchedBy: "remote" | "paths";
|
|
6
|
+
}
|
|
7
|
+
export interface ProjectFile {
|
|
8
|
+
path: string;
|
|
9
|
+
source: "root" | "overlay";
|
|
10
|
+
}
|
|
11
|
+
export interface ResolvedProjectConfig {
|
|
12
|
+
config: ProjectConfig;
|
|
13
|
+
source: "root" | "overlay";
|
|
14
|
+
overlay?: Overlay;
|
|
15
|
+
}
|
|
16
|
+
export declare function findOverlay(cwd: string, env: NodeJS.ProcessEnv, home: string): Overlay | undefined;
|
|
17
|
+
export declare function normalizeRemoteUrl(url: string): string;
|
|
18
|
+
export declare function resolveProjectFile(cwd: string, overlay: Overlay | undefined, name: string): ProjectFile | undefined;
|
|
19
|
+
export declare function resolveProjectConfig(cwd: string, env: NodeJS.ProcessEnv, home: string): ResolvedProjectConfig | undefined;
|
package/dist/overlay.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { existsSync, readdirSync, realpathSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CliError } from "./cli-error.js";
|
|
4
|
+
import { gitOutputOrUndefined } from "./git.js";
|
|
5
|
+
import { readProjectConfig } from "./project-config.js";
|
|
6
|
+
export function findOverlay(cwd, env, home) {
|
|
7
|
+
const configuredDir = env.ALIGNFIRST_OVERLAYS;
|
|
8
|
+
if (configuredDir === undefined || configuredDir === "")
|
|
9
|
+
return;
|
|
10
|
+
const overlaysDir = expandHome(configuredDir, home);
|
|
11
|
+
const candidates = readOverlayCandidates(overlaysDir);
|
|
12
|
+
const origin = gitOutputOrUndefined(cwd, "remote", "get-url", "origin");
|
|
13
|
+
const normalizedOrigin = origin === undefined || origin === "" ? undefined : normalizeRemoteUrl(origin);
|
|
14
|
+
const realCwd = realpathSync(cwd);
|
|
15
|
+
const remoteMatches = candidates.filter(({ config }) => normalizedOrigin !== undefined && config.project?.remote === normalizedOrigin);
|
|
16
|
+
if (remoteMatches.length > 0)
|
|
17
|
+
return selectOverlay(remoteMatches, "remote");
|
|
18
|
+
const pathMatches = candidates.filter(({ config }) => config.project?.paths?.includes(realCwd));
|
|
19
|
+
if (pathMatches.length > 0)
|
|
20
|
+
return selectOverlay(pathMatches, "paths");
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
function expandHome(path, home) {
|
|
24
|
+
return path.startsWith("~/") ? join(home, path.slice(2)) : path;
|
|
25
|
+
}
|
|
26
|
+
function readOverlayCandidates(overlaysDir) {
|
|
27
|
+
if (!existsSync(overlaysDir))
|
|
28
|
+
return [];
|
|
29
|
+
return readdirSync(overlaysDir, { withFileTypes: true })
|
|
30
|
+
.filter((entry) => entry.isDirectory())
|
|
31
|
+
.flatMap((entry) => {
|
|
32
|
+
const dir = join(overlaysDir, entry.name, "_project");
|
|
33
|
+
const config = readProjectConfig(dir);
|
|
34
|
+
return config === undefined ? [] : [{ dir, config }];
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function selectOverlay(candidates, matchedBy) {
|
|
38
|
+
if (candidates.length > 1)
|
|
39
|
+
throw new CliError(`Multiple AlignFirst overlays match this project: ${candidates
|
|
40
|
+
.map(({ dir }) => dir)
|
|
41
|
+
.join(", ")}`);
|
|
42
|
+
const candidate = candidates[0];
|
|
43
|
+
return { ...candidate, matchedBy };
|
|
44
|
+
}
|
|
45
|
+
export function normalizeRemoteUrl(url) {
|
|
46
|
+
const value = url
|
|
47
|
+
.trim()
|
|
48
|
+
.replace(/\/+$/, "")
|
|
49
|
+
.replace(/\.git$/, "");
|
|
50
|
+
const scpMatch = value.includes("://") ? null : /^(?:[^@]+@)?([^:/]+):(.+)$/.exec(value);
|
|
51
|
+
if (scpMatch)
|
|
52
|
+
return `${scpMatch[1].toLowerCase()}/${scpMatch[2].replace(/^\/+/, "")}`;
|
|
53
|
+
try {
|
|
54
|
+
const parsed = new URL(value.includes("://") ? value : `https://${value}`);
|
|
55
|
+
return `${parsed.hostname.toLowerCase()}${parsed.pathname}`
|
|
56
|
+
.replace(/\/+$/, "")
|
|
57
|
+
.replace(/\.git$/, "")
|
|
58
|
+
.replace(/^\/+/, "");
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function resolveProjectFile(cwd, overlay, name) {
|
|
65
|
+
const rootPath = join(cwd, name);
|
|
66
|
+
if (existsSync(rootPath))
|
|
67
|
+
return { path: rootPath, source: "root" };
|
|
68
|
+
if (overlay === undefined)
|
|
69
|
+
return;
|
|
70
|
+
const overlayPath = join(overlay.dir, name);
|
|
71
|
+
if (existsSync(overlayPath))
|
|
72
|
+
return { path: overlayPath, source: "overlay" };
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
export function resolveProjectConfig(cwd, env, home) {
|
|
76
|
+
const overlay = findOverlay(cwd, env, home);
|
|
77
|
+
const rootConfig = readProjectConfig(cwd);
|
|
78
|
+
if (rootConfig !== undefined)
|
|
79
|
+
return { config: rootConfig, source: "root", overlay };
|
|
80
|
+
if (overlay !== undefined)
|
|
81
|
+
return { config: overlay.config, source: "overlay", overlay };
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function parseCommandArgs<T>(usage: string, parse: () => T): T;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { CliError } from "./cli-error.js";
|
|
2
|
+
import { errorMessage } from "./errors.js";
|
|
3
|
+
export function parseCommandArgs(usage, parse) {
|
|
4
|
+
try {
|
|
5
|
+
return parse();
|
|
6
|
+
}
|
|
7
|
+
catch (error) {
|
|
8
|
+
const detail = errorMessage(error).split("\n", 1)[0];
|
|
9
|
+
throw new CliError(`${detail}\n\n${usage}`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Output } from "../context.js";
|
|
2
|
+
export declare function archiveThresholdDays(env: NodeJS.ProcessEnv): number;
|
|
3
|
+
export declare function autoArchive(plansDir: string, thresholdDays: number, stdout: Output): boolean;
|
|
4
|
+
export declare function archiveEntry(plansDir: string, sourcePath: string, stdout: Output): void;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, renameSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, extname, join, relative } from "node:path";
|
|
3
|
+
import { CliError } from "../cli-error.js";
|
|
4
|
+
import { isTicketName } from "./layout.js";
|
|
5
|
+
const DEFAULT_ARCHIVE_DAYS = 7;
|
|
6
|
+
const DAY_MS = 86_400_000;
|
|
7
|
+
export function archiveThresholdDays(env) {
|
|
8
|
+
const value = env.ALIGNFIRST_ARCHIVE_DAYS;
|
|
9
|
+
if (value === undefined)
|
|
10
|
+
return DEFAULT_ARCHIVE_DAYS;
|
|
11
|
+
const days = Number(value);
|
|
12
|
+
if (!Number.isFinite(days) || days <= 0)
|
|
13
|
+
throw new CliError("ALIGNFIRST_ARCHIVE_DAYS must be a positive number of days.");
|
|
14
|
+
return days;
|
|
15
|
+
}
|
|
16
|
+
export function autoArchive(plansDir, thresholdDays, stdout) {
|
|
17
|
+
const cutoff = Date.now() - thresholdDays * DAY_MS;
|
|
18
|
+
const candidates = [
|
|
19
|
+
...staleTicketDirectories(plansDir, cutoff),
|
|
20
|
+
...staleNoTicketSessionFiles(plansDir, cutoff),
|
|
21
|
+
];
|
|
22
|
+
if (candidates.length === 0) {
|
|
23
|
+
stdout.write("Nothing to archive.\n");
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
for (const candidate of candidates)
|
|
27
|
+
archiveEntry(plansDir, candidate, stdout);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
function staleTicketDirectories(plansDir, cutoff) {
|
|
31
|
+
return readdirSync(plansDir, { withFileTypes: true })
|
|
32
|
+
.filter((entry) => entry.isDirectory() && isTicketName(entry.name))
|
|
33
|
+
.map((entry) => join(plansDir, entry.name))
|
|
34
|
+
.filter((ticketDir) => newestFileMtime(ticketDir) < cutoff);
|
|
35
|
+
}
|
|
36
|
+
function newestFileMtime(dir) {
|
|
37
|
+
const files = readdirSync(dir, { withFileTypes: true, recursive: true }).filter((entry) => entry.isFile());
|
|
38
|
+
if (files.length === 0)
|
|
39
|
+
return statSync(dir).mtimeMs;
|
|
40
|
+
return Math.max(...files.map((entry) => statSync(join(entry.parentPath, entry.name)).mtimeMs));
|
|
41
|
+
}
|
|
42
|
+
function staleNoTicketSessionFiles(plansDir, cutoff) {
|
|
43
|
+
const sessionDir = join(plansDir, "_alcode");
|
|
44
|
+
if (!existsSync(sessionDir))
|
|
45
|
+
return [];
|
|
46
|
+
return readdirSync(sessionDir, { withFileTypes: true })
|
|
47
|
+
.filter((entry) => entry.isFile())
|
|
48
|
+
.map((entry) => join(sessionDir, entry.name))
|
|
49
|
+
.filter((path) => statSync(path).mtimeMs < cutoff);
|
|
50
|
+
}
|
|
51
|
+
export function archiveEntry(plansDir, sourcePath, stdout) {
|
|
52
|
+
const rel = relative(plansDir, sourcePath);
|
|
53
|
+
const archivesDir = join(plansDir, "_archives");
|
|
54
|
+
const targetDir = join(archivesDir, dirname(rel));
|
|
55
|
+
mkdirSync(targetDir, { recursive: true });
|
|
56
|
+
const target = moveToFreeName(sourcePath, targetDir, statSync(sourcePath).isFile());
|
|
57
|
+
stdout.write(`Archived ${rel} → _archives/${relative(archivesDir, target)}\n`);
|
|
58
|
+
}
|
|
59
|
+
function moveToFreeName(sourcePath, targetDir, isFile) {
|
|
60
|
+
const name = basename(sourcePath);
|
|
61
|
+
const ext = isFile ? extname(name) : "";
|
|
62
|
+
const stem = name.slice(0, name.length - ext.length);
|
|
63
|
+
let candidate = join(targetDir, name);
|
|
64
|
+
for (let suffix = 2; existsSync(candidate); ++suffix)
|
|
65
|
+
candidate = join(targetDir, `${stem}-${suffix}${ext}`);
|
|
66
|
+
renameSync(sourcePath, candidate);
|
|
67
|
+
return candidate;
|
|
68
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const PLANS_DIR = ".plans";
|
|
2
|
+
export declare const ARCHIVES_DIR = "_archives";
|
|
3
|
+
export declare function isTicketName(name: string): boolean;
|
|
4
|
+
export declare function plansDir(cwd: string): string;
|
|
5
|
+
export declare function archivesDir(cwd: string): string;
|
|
6
|
+
export declare function assertPlansGate(cwd: string, form: string): void;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CliError } from "../cli-error.js";
|
|
4
|
+
export const PLANS_DIR = ".plans";
|
|
5
|
+
export const ARCHIVES_DIR = "_archives";
|
|
6
|
+
export function isTicketName(name) {
|
|
7
|
+
return !name.startsWith("_");
|
|
8
|
+
}
|
|
9
|
+
export function plansDir(cwd) {
|
|
10
|
+
return join(cwd, PLANS_DIR);
|
|
11
|
+
}
|
|
12
|
+
export function archivesDir(cwd) {
|
|
13
|
+
return join(plansDir(cwd), ARCHIVES_DIR);
|
|
14
|
+
}
|
|
15
|
+
export function assertPlansGate(cwd, form) {
|
|
16
|
+
if (existsSync(plansDir(cwd)))
|
|
17
|
+
return;
|
|
18
|
+
throw new CliError(`Error: no \`.plans/\` directory found in the current directory. Run \`${form}\` from the root of an AlignFirst-managed project.`);
|
|
19
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { cpSync, existsSync, lstatSync, readdirSync, realpathSync, rmSync, symlinkSync, } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { CliError } from "../cli-error.js";
|
|
4
|
+
export function linkPlans(ctx, targetDir) {
|
|
5
|
+
const plansPath = join(ctx.cwd, ".plans");
|
|
6
|
+
const stats = lstatSync(plansPath, { throwIfNoEntry: false });
|
|
7
|
+
if (stats?.isSymbolicLink()) {
|
|
8
|
+
if (existsSync(plansPath) && realpathSync(plansPath) === realpathSync(targetDir)) {
|
|
9
|
+
ctx.stdout.write(".plans already links to the plans repository.\n");
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
rmSync(plansPath);
|
|
13
|
+
}
|
|
14
|
+
else if (stats?.isDirectory()) {
|
|
15
|
+
migratePlansContent(ctx, plansPath, targetDir);
|
|
16
|
+
}
|
|
17
|
+
else if (stats) {
|
|
18
|
+
throw new CliError(".plans exists and is not a directory.");
|
|
19
|
+
}
|
|
20
|
+
const target = relative(ctx.cwd, targetDir);
|
|
21
|
+
symlinkSync(target, plansPath);
|
|
22
|
+
ctx.stdout.write(`Linked .plans → ${target}\n`);
|
|
23
|
+
ctx.stdout.write(`Publish with: ${ctx.form} sync\n`);
|
|
24
|
+
}
|
|
25
|
+
function migratePlansContent(ctx, plansPath, targetDir) {
|
|
26
|
+
const entries = readdirSync(plansPath);
|
|
27
|
+
const collisions = entries.filter((entry) => existsSync(join(targetDir, entry)));
|
|
28
|
+
if (collisions.length > 0)
|
|
29
|
+
throw new CliError(`Cannot migrate .plans: already in ${targetDir}: ${collisions.join(", ")}. ` +
|
|
30
|
+
"Merge them manually, then re-run.");
|
|
31
|
+
for (const entry of entries)
|
|
32
|
+
cpSync(join(plansPath, entry), join(targetDir, entry), { recursive: true });
|
|
33
|
+
rmSync(plansPath, { recursive: true });
|
|
34
|
+
if (entries.length > 0)
|
|
35
|
+
ctx.stdout.write(`Migrated ${entries.length} entries from the local .plans directory.\n`);
|
|
36
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { existsSync, lstatSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CliError } from "../cli-error.js";
|
|
4
|
+
import { gitOutput } from "../git.js";
|
|
5
|
+
export function resolvePlansMode(cwd, form) {
|
|
6
|
+
const plansPath = join(cwd, ".plans");
|
|
7
|
+
const stats = lstatSync(plansPath, { throwIfNoEntry: false });
|
|
8
|
+
if (!stats)
|
|
9
|
+
throw new CliError(`.plans is missing. Clone the team plans repository, then run ${form} plans setup ` +
|
|
10
|
+
"(see the project documentation) — or create a plain .plans directory to keep plans local.");
|
|
11
|
+
if (stats.isSymbolicLink() && !existsSync(plansPath))
|
|
12
|
+
throw new CliError(`The .plans symlink is broken. Re-run ${form} plans setup with the clone location.`);
|
|
13
|
+
if (!statSync(plansPath).isDirectory())
|
|
14
|
+
throw new CliError(`.plans is not a directory. Remove it, then run ${form} plans setup (see the project documentation).`);
|
|
15
|
+
if (plansRepositoryId(plansPath, stats.isSymbolicLink(), form) === repositoryId(cwd))
|
|
16
|
+
return { kind: "local" };
|
|
17
|
+
return { kind: "shared", repoToplevel: gitOutput(plansPath, "rev-parse", "--show-toplevel") };
|
|
18
|
+
}
|
|
19
|
+
function plansRepositoryId(plansPath, isSymlink, form) {
|
|
20
|
+
try {
|
|
21
|
+
return repositoryId(plansPath);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
if (isSymlink)
|
|
25
|
+
throw new CliError(`.plans points outside any git repository. Re-run ${form} plans setup with the clone location.`);
|
|
26
|
+
throw new CliError(".plans is not inside a git repository. Run this command from a worktree root.");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function repositoryId(dir) {
|
|
30
|
+
return realpathSync(gitOutput(dir, "rev-parse", "--path-format=absolute", "--git-common-dir"));
|
|
31
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface ResolvedTicketDir {
|
|
2
|
+
id: string;
|
|
3
|
+
dir: string;
|
|
4
|
+
state: "existing" | "created" | "restored";
|
|
5
|
+
entries: string[];
|
|
6
|
+
}
|
|
7
|
+
export interface ResolveTicketOptions {
|
|
8
|
+
dryRun: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface DeducedTicket {
|
|
11
|
+
id: string;
|
|
12
|
+
branch: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function resolveTicketDir(cwd: string, id: string, { dryRun }: ResolveTicketOptions): ResolvedTicketDir;
|
|
15
|
+
export declare function reserveSideTicket(cwd: string): string;
|
|
16
|
+
export declare function peekSideTicket(cwd: string): string;
|
|
17
|
+
export declare function nextFileName(dir: string, filename: string, newCycle: boolean): string;
|
|
18
|
+
export declare function listEntries(dir: string): string[];
|
|
19
|
+
export declare function isPathSafeTicketId(id: string): boolean;
|
|
20
|
+
export declare function validateTicketId(id: string, pattern?: string): void;
|
|
21
|
+
export declare function deduceTicketFromBranch(cwd: string, pattern: string): DeducedTicket;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, renameSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CliError } from "../cli-error.js";
|
|
4
|
+
import { isNodeError } from "../errors.js";
|
|
5
|
+
import { gitOutputOrUndefined } from "../git.js";
|
|
6
|
+
import { archivesDir, plansDir } from "./layout.js";
|
|
7
|
+
const FILE_PREFIX = /^([A-Z])(\d+)-/;
|
|
8
|
+
const SIDE_TICKET = /^side-(\d+)$/;
|
|
9
|
+
const PATH_SAFE_TICKET = /^[A-Za-z0-9._-]+$/;
|
|
10
|
+
export function resolveTicketDir(cwd, id, { dryRun }) {
|
|
11
|
+
const dir = join(plansDir(cwd), id);
|
|
12
|
+
if (existsSync(dir))
|
|
13
|
+
return { id, dir, state: "existing", entries: listEntries(dir) };
|
|
14
|
+
const archivedDir = join(archivesDir(cwd), id);
|
|
15
|
+
if (existsSync(archivedDir)) {
|
|
16
|
+
const entries = listEntries(archivedDir);
|
|
17
|
+
if (!dryRun)
|
|
18
|
+
renameSync(archivedDir, dir);
|
|
19
|
+
return { id, dir, state: "restored", entries };
|
|
20
|
+
}
|
|
21
|
+
if (!dryRun)
|
|
22
|
+
mkdirSync(dir);
|
|
23
|
+
return { id, dir, state: "created", entries: [] };
|
|
24
|
+
}
|
|
25
|
+
export function reserveSideTicket(cwd) {
|
|
26
|
+
const root = plansDir(cwd);
|
|
27
|
+
const highest = Math.max(highestSideTicket(root), highestSideTicket(archivesDir(cwd)));
|
|
28
|
+
for (let number = highest + 1;; ++number) {
|
|
29
|
+
const ticket = `side-${number}`;
|
|
30
|
+
try {
|
|
31
|
+
mkdirSync(join(root, ticket));
|
|
32
|
+
return ticket;
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (!isNodeError(error) || error.code !== "EEXIST")
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function peekSideTicket(cwd) {
|
|
41
|
+
const root = plansDir(cwd);
|
|
42
|
+
const highest = Math.max(highestSideTicket(root), highestSideTicket(archivesDir(cwd)));
|
|
43
|
+
for (let number = highest + 1;; ++number) {
|
|
44
|
+
const ticket = `side-${number}`;
|
|
45
|
+
if (!existsSync(join(root, ticket)))
|
|
46
|
+
return ticket;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function highestSideTicket(dir) {
|
|
50
|
+
let highest = 0;
|
|
51
|
+
for (const entry of readEntries(dir)) {
|
|
52
|
+
const match = entry.isDirectory() ? SIDE_TICKET.exec(entry.name) : null;
|
|
53
|
+
if (match)
|
|
54
|
+
highest = Math.max(highest, Number(match[1]));
|
|
55
|
+
}
|
|
56
|
+
return highest;
|
|
57
|
+
}
|
|
58
|
+
function readEntries(dir) {
|
|
59
|
+
try {
|
|
60
|
+
return readdirSync(dir, { withFileTypes: true });
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export function nextFileName(dir, filename, newCycle) {
|
|
67
|
+
const prefixes = readEntries(dir).flatMap((entry) => {
|
|
68
|
+
const match = FILE_PREFIX.exec(entry.name);
|
|
69
|
+
return match ? [{ cycle: match[1], number: Number(match[2]) }] : [];
|
|
70
|
+
});
|
|
71
|
+
if (prefixes.length === 0)
|
|
72
|
+
return `A1-${filename}`;
|
|
73
|
+
const highestCycle = prefixes.reduce((highest, prefix) => (prefix.cycle > highest ? prefix.cycle : highest), "A");
|
|
74
|
+
if (newCycle)
|
|
75
|
+
return `${String.fromCharCode(highestCycle.charCodeAt(0) + 1)}1-${filename}`;
|
|
76
|
+
const highestNumber = Math.max(...prefixes.filter(({ cycle }) => cycle === highestCycle).map(({ number }) => number));
|
|
77
|
+
return `${highestCycle}${highestNumber + 1}-${filename}`;
|
|
78
|
+
}
|
|
79
|
+
export function listEntries(dir) {
|
|
80
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory())
|
|
81
|
+
return [];
|
|
82
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
83
|
+
.map((entry) => `${entry.name}${entry.isDirectory() ? "/" : ""}`)
|
|
84
|
+
.toSorted();
|
|
85
|
+
}
|
|
86
|
+
export function isPathSafeTicketId(id) {
|
|
87
|
+
return id !== "." && !id.includes("..") && PATH_SAFE_TICKET.test(id);
|
|
88
|
+
}
|
|
89
|
+
export function validateTicketId(id, pattern) {
|
|
90
|
+
if (!isPathSafeTicketId(id))
|
|
91
|
+
throw new CliError(`Invalid ticket id: ${id}`);
|
|
92
|
+
if (pattern !== undefined && !new RegExp(pattern).test(id) && !SIDE_TICKET.test(id))
|
|
93
|
+
throw new CliError(`Ticket id "${id}" does not match ticketPattern "${pattern}".`);
|
|
94
|
+
}
|
|
95
|
+
export function deduceTicketFromBranch(cwd, pattern) {
|
|
96
|
+
const branch = gitOutputOrUndefined(cwd, "branch", "--show-current");
|
|
97
|
+
if (branch === undefined || branch === "")
|
|
98
|
+
throw new CliError("Cannot deduce a ticket id from a detached HEAD.");
|
|
99
|
+
const unanchored = pattern.replace(/^\^/, "").replace(/\$$/, "");
|
|
100
|
+
const match = new RegExp(unanchored).exec(branch);
|
|
101
|
+
if (!match)
|
|
102
|
+
throw new CliError(`Cannot deduce a ticket id from branch "${branch}" with pattern "${pattern}".`);
|
|
103
|
+
return { id: match[0], branch };
|
|
104
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export declare const PROJECT_CONFIG_FILENAME = ".alignfirst.json";
|
|
2
|
+
export declare const portRangeSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
|
3
|
+
first: number;
|
|
4
|
+
last: number;
|
|
5
|
+
}, {}>;
|
|
6
|
+
export interface ProjectConfig {
|
|
7
|
+
schemaVersion: 1;
|
|
8
|
+
cli?: string;
|
|
9
|
+
ticketPattern?: string;
|
|
10
|
+
plans?: PlansConfig;
|
|
11
|
+
portRange?: PortRange;
|
|
12
|
+
project?: ProjectIdentity;
|
|
13
|
+
}
|
|
14
|
+
export interface PlansConfig {
|
|
15
|
+
folder: string;
|
|
16
|
+
}
|
|
17
|
+
export interface PortRange {
|
|
18
|
+
first: number;
|
|
19
|
+
last: number;
|
|
20
|
+
}
|
|
21
|
+
export interface ProjectIdentity {
|
|
22
|
+
remote?: string;
|
|
23
|
+
paths?: string[];
|
|
24
|
+
}
|
|
25
|
+
export declare function validateProjectConfig(value: unknown, label: string): ProjectConfig;
|
|
26
|
+
export declare function assertValidPortRange(range: PortRange, label: string): void;
|
|
27
|
+
export declare function readProjectConfig(dir: string): ProjectConfig | undefined;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { type } from "arktype";
|
|
4
|
+
import semver from "semver";
|
|
5
|
+
import { CliError } from "./cli-error.js";
|
|
6
|
+
import { errorMessage } from "./errors.js";
|
|
7
|
+
export const PROJECT_CONFIG_FILENAME = ".alignfirst.json";
|
|
8
|
+
export const portRangeSchema = type({
|
|
9
|
+
"+": "reject",
|
|
10
|
+
first: "1 <= number.integer <= 65535",
|
|
11
|
+
last: "1 <= number.integer <= 65535",
|
|
12
|
+
});
|
|
13
|
+
const plansSchema = type({
|
|
14
|
+
"+": "reject",
|
|
15
|
+
folder: "string > 0",
|
|
16
|
+
});
|
|
17
|
+
const projectSchema = type({
|
|
18
|
+
"+": "reject",
|
|
19
|
+
"remote?": "string > 0",
|
|
20
|
+
"paths?": "string[]",
|
|
21
|
+
});
|
|
22
|
+
const projectConfigSchema = type({
|
|
23
|
+
"+": "reject",
|
|
24
|
+
schemaVersion: "1",
|
|
25
|
+
"cli?": "string > 0",
|
|
26
|
+
"ticketPattern?": "string > 0",
|
|
27
|
+
"plans?": plansSchema,
|
|
28
|
+
"portRange?": portRangeSchema,
|
|
29
|
+
"project?": projectSchema,
|
|
30
|
+
});
|
|
31
|
+
export function validateProjectConfig(value, label) {
|
|
32
|
+
const config = projectConfigSchema(value);
|
|
33
|
+
if (config instanceof type.errors)
|
|
34
|
+
throw invalidConfig(label, config.summary.split("\n", 1)[0]);
|
|
35
|
+
if (config.cli !== undefined && semver.validRange(config.cli) === null)
|
|
36
|
+
throw invalidConfig(label, `cli is not a valid semver range: ${config.cli}`);
|
|
37
|
+
if (config.ticketPattern !== undefined)
|
|
38
|
+
assertValidPattern(config.ticketPattern, label);
|
|
39
|
+
if (config.portRange !== undefined)
|
|
40
|
+
assertValidPortRange(config.portRange, label);
|
|
41
|
+
if (config.project !== undefined)
|
|
42
|
+
assertValidProjectIdentity(config.project, label);
|
|
43
|
+
return config;
|
|
44
|
+
}
|
|
45
|
+
function invalidConfig(label, detail) {
|
|
46
|
+
return new CliError(`Invalid ${label}: ${detail}`);
|
|
47
|
+
}
|
|
48
|
+
function assertValidPattern(pattern, label) {
|
|
49
|
+
try {
|
|
50
|
+
new RegExp(pattern);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
throw invalidConfig(label, `ticketPattern is not a valid regular expression: ${errorMessage(error)}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function assertValidPortRange(range, label) {
|
|
57
|
+
if (range.first > range.last)
|
|
58
|
+
throw invalidConfig(label, "portRange.first must not exceed portRange.last");
|
|
59
|
+
}
|
|
60
|
+
function assertValidProjectIdentity(project, label) {
|
|
61
|
+
if (project.remote === undefined && project.paths === undefined)
|
|
62
|
+
throw invalidConfig(label, "project must contain remote or paths");
|
|
63
|
+
const relativePath = project.paths?.find((path) => !isAbsolute(path));
|
|
64
|
+
if (relativePath !== undefined)
|
|
65
|
+
throw invalidConfig(label, `project.paths must contain only absolute paths: ${relativePath}`);
|
|
66
|
+
}
|
|
67
|
+
export function readProjectConfig(dir) {
|
|
68
|
+
const path = join(dir, PROJECT_CONFIG_FILENAME);
|
|
69
|
+
if (!existsSync(path))
|
|
70
|
+
return;
|
|
71
|
+
let value;
|
|
72
|
+
try {
|
|
73
|
+
value = JSON.parse(readFileSync(path, "utf-8"));
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
throw invalidConfig(path, errorMessage(error));
|
|
77
|
+
}
|
|
78
|
+
return validateProjectConfig(value, path);
|
|
79
|
+
}
|
package/dist/skills.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CommandContext } from "./context.js";
|
|
2
|
+
export declare const STUB_SKILLS: readonly ["alignfirst", "alspec", "alplan", "al", "alcatchup", "almerge", "alreview", "aldescription"];
|
|
3
|
+
export declare const SKILL_ROOTS: readonly [".agents/skills", ".claude/skills", ".codex/skills"];
|
|
4
|
+
export interface InstalledSkill {
|
|
5
|
+
root: string;
|
|
6
|
+
version: string | undefined;
|
|
7
|
+
}
|
|
8
|
+
export declare function findInstalledSkill(home: string, name: string): InstalledSkill | undefined;
|
|
9
|
+
export declare function installStubSkills(ctx: CommandContext, agents: string[]): void;
|