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.
Files changed (74) hide show
  1. package/README.md +31 -0
  2. package/bin/alignfirst.mjs +3 -0
  3. package/dist/cli-error.d.ts +2 -0
  4. package/dist/cli-error.js +2 -0
  5. package/dist/cli.d.ts +10 -0
  6. package/dist/cli.js +98 -0
  7. package/dist/command-form.d.ts +3 -0
  8. package/dist/command-form.js +8 -0
  9. package/dist/commands/config.d.ts +2 -0
  10. package/dist/commands/config.js +55 -0
  11. package/dist/commands/developers.d.ts +2 -0
  12. package/dist/commands/developers.js +36 -0
  13. package/dist/commands/docmap.d.ts +2 -0
  14. package/dist/commands/docmap.js +22 -0
  15. package/dist/commands/doctor.d.ts +2 -0
  16. package/dist/commands/doctor.js +167 -0
  17. package/dist/commands/guide.d.ts +2 -0
  18. package/dist/commands/guide.js +126 -0
  19. package/dist/commands/plans.d.ts +2 -0
  20. package/dist/commands/plans.js +154 -0
  21. package/dist/commands/setup.d.ts +2 -0
  22. package/dist/commands/setup.js +254 -0
  23. package/dist/commands/sync.d.ts +2 -0
  24. package/dist/commands/sync.js +63 -0
  25. package/dist/commands/ticket.d.ts +2 -0
  26. package/dist/commands/ticket.js +119 -0
  27. package/dist/context.d.ts +15 -0
  28. package/dist/context.js +1 -0
  29. package/dist/errors.d.ts +2 -0
  30. package/dist/errors.js +6 -0
  31. package/dist/executables.d.ts +1 -0
  32. package/dist/executables.js +24 -0
  33. package/dist/git.d.ts +5 -0
  34. package/dist/git.js +52 -0
  35. package/dist/overlay.d.ts +19 -0
  36. package/dist/overlay.js +83 -0
  37. package/dist/parse-args.d.ts +1 -0
  38. package/dist/parse-args.js +11 -0
  39. package/dist/plans/archive.d.ts +4 -0
  40. package/dist/plans/archive.js +68 -0
  41. package/dist/plans/layout.d.ts +6 -0
  42. package/dist/plans/layout.js +19 -0
  43. package/dist/plans/link.d.ts +2 -0
  44. package/dist/plans/link.js +36 -0
  45. package/dist/plans/mode.d.ts +9 -0
  46. package/dist/plans/mode.js +31 -0
  47. package/dist/plans/ticket.d.ts +21 -0
  48. package/dist/plans/ticket.js +104 -0
  49. package/dist/project-config.d.ts +27 -0
  50. package/dist/project-config.js +79 -0
  51. package/dist/protocols.d.ts +2 -0
  52. package/dist/protocols.js +9 -0
  53. package/dist/skills.d.ts +9 -0
  54. package/dist/skills.js +54 -0
  55. package/dist/version-guard.d.ts +8 -0
  56. package/dist/version-guard.js +24 -0
  57. package/package.json +48 -0
  58. package/templates/guide/code-review/correctness-reviewer.md +53 -0
  59. package/templates/guide/code-review/intent-reviewer.md +22 -0
  60. package/templates/guide/code-review/module-javascript.md +80 -0
  61. package/templates/guide/code-review/module-python.md +77 -0
  62. package/templates/guide/code-review/module-typescript-strict.md +84 -0
  63. package/templates/guide/code-review/quality-reviewer.md +54 -0
  64. package/templates/guide/code-review/reviewer-common.md +62 -0
  65. package/templates/guide/code-review/safety-reviewer.md +66 -0
  66. package/templates/guide/core.md +54 -0
  67. package/templates/guide/overview.md +57 -0
  68. package/templates/guide/protocols/aad.md +85 -0
  69. package/templates/guide/protocols/catchup.md +13 -0
  70. package/templates/guide/protocols/description.md +51 -0
  71. package/templates/guide/protocols/merge.md +65 -0
  72. package/templates/guide/protocols/plan.md +256 -0
  73. package/templates/guide/protocols/review.md +114 -0
  74. package/templates/guide/protocols/spec.md +78 -0
@@ -0,0 +1,154 @@
1
+ import { existsSync, mkdirSync, realpathSync, statSync } from "node:fs";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ import { parseArgs } from "node:util";
4
+ import { CliError } from "../cli-error.js";
5
+ import { assertMainWorktreeRoot } from "../git.js";
6
+ import { parseCommandArgs } from "../parse-args.js";
7
+ import { archiveEntry, archiveThresholdDays, autoArchive } from "../plans/archive.js";
8
+ import { linkPlans } from "../plans/link.js";
9
+ import { resolvePlansMode } from "../plans/mode.js";
10
+ export function runPlans(ctx, args) {
11
+ const [command, ...rest] = args;
12
+ switch (command) {
13
+ case "setup":
14
+ return runSetup(ctx, rest);
15
+ case "check":
16
+ return runCheck(ctx, rest);
17
+ case "archive":
18
+ return runArchive(ctx, rest);
19
+ case "auto-archive":
20
+ return runAutoArchive(ctx, rest);
21
+ case "--help":
22
+ case "-h":
23
+ ctx.stdout.write(plansUsage(ctx));
24
+ return 0;
25
+ default:
26
+ throw new CliError(`Unknown or missing plans command.\n\n${plansUsage(ctx)}`);
27
+ }
28
+ }
29
+ function plansUsage(ctx) {
30
+ return `Usage:
31
+ ${ctx.form} plans setup <clone-dir> [--folder <name>]
32
+ ${ctx.form} plans check
33
+ ${ctx.form} plans archive <ticket-id | path>
34
+ ${ctx.form} plans auto-archive
35
+ `;
36
+ }
37
+ function runSetup(ctx, args) {
38
+ const usage = `Usage: ${ctx.form} plans setup <clone-dir> [--folder <name>]\n`;
39
+ const parsed = parseSetupArgs(ctx, args, usage);
40
+ if (parsed === undefined)
41
+ return 0;
42
+ assertMainWorktreeRoot(ctx.cwd);
43
+ const cloneDir = resolve(ctx.cwd, parsed.dir);
44
+ checkClone(ctx, cloneDir);
45
+ const projectDir = join(cloneDir, parsed.folder);
46
+ mkdirSync(projectDir, { recursive: true });
47
+ linkPlans(ctx, projectDir);
48
+ return 0;
49
+ }
50
+ function parseSetupArgs(ctx, args, usage) {
51
+ const { values, positionals } = parseCommandArgs(usage, () => parseArgs({
52
+ args,
53
+ options: {
54
+ folder: { type: "string" },
55
+ help: { type: "boolean", short: "h", default: false },
56
+ },
57
+ strict: true,
58
+ allowPositionals: true,
59
+ }));
60
+ if (values.help) {
61
+ ctx.stdout.write(usage);
62
+ return;
63
+ }
64
+ if (positionals.length !== 1)
65
+ throw new CliError(usage.trimEnd());
66
+ const configFolder = ctx.projectConfig?.config.plans?.folder;
67
+ if (values.folder !== undefined && configFolder !== undefined)
68
+ throw new CliError(".alignfirst.json already sets plans.folder; drop --folder.");
69
+ const folder = values.folder ?? configFolder;
70
+ if (folder === undefined)
71
+ throw new CliError("Pass --folder <name> or set plans.folder in .alignfirst.json.");
72
+ return { dir: positionals[0], folder };
73
+ }
74
+ function checkClone(ctx, cloneDir) {
75
+ if (!existsSync(cloneDir))
76
+ throw new CliError(`${cloneDir} does not exist. Clone the team plans repository there first (see the instruction file).`);
77
+ if (!existsSync(join(cloneDir, ".git")))
78
+ throw new CliError(`${cloneDir} is not a git repository. Point ${ctx.form} plans setup at a clone of the team plans repository.`);
79
+ if (realpathSync(cloneDir) === realpathSync(ctx.cwd))
80
+ throw new CliError(`${cloneDir} is the product repository itself. Point ${ctx.form} plans setup at a clone of the team plans repository.`);
81
+ }
82
+ function runCheck(ctx, args) {
83
+ const usage = `Usage: ${ctx.form} plans check\n`;
84
+ if (handleBareHelp(ctx, args, usage))
85
+ return 0;
86
+ const mode = resolvePlansMode(ctx.cwd, ctx.form);
87
+ if (mode.kind === "shared")
88
+ ctx.stdout.write(".plans is linked to the team plans repository.\n");
89
+ else
90
+ ctx.stdout.write(".plans is a local directory (local plans mode): synchronization is disabled.\n");
91
+ return 0;
92
+ }
93
+ function handleBareHelp(ctx, args, usage) {
94
+ const { values, positionals } = parseCommandArgs(usage, () => parseArgs({
95
+ args,
96
+ options: { help: { type: "boolean", short: "h", default: false } },
97
+ strict: true,
98
+ allowPositionals: true,
99
+ }));
100
+ if (positionals.length > 0)
101
+ throw new CliError(`Unexpected argument: ${positionals[0]}\n\n${usage}`);
102
+ if (!values.help)
103
+ return false;
104
+ ctx.stdout.write(usage);
105
+ return true;
106
+ }
107
+ function runAutoArchive(ctx, args) {
108
+ const usage = `Usage: ${ctx.form} plans auto-archive\n`;
109
+ if (handleBareHelp(ctx, args, usage))
110
+ return 0;
111
+ const mode = resolvePlansMode(ctx.cwd, ctx.form);
112
+ const archived = autoArchive(join(ctx.cwd, ".plans"), archiveThresholdDays(ctx.env), ctx.stdout);
113
+ if (mode.kind === "shared" && archived)
114
+ ctx.stdout.write(`Publish with: ${ctx.form} sync\n`);
115
+ return 0;
116
+ }
117
+ function runArchive(ctx, args) {
118
+ const usage = `Usage: ${ctx.form} plans archive <ticket-id | path>\n`;
119
+ const target = resolveArchiveTarget(ctx, args, usage);
120
+ if (target === undefined)
121
+ return 0;
122
+ const mode = resolvePlansMode(ctx.cwd, ctx.form);
123
+ const root = join(ctx.cwd, ".plans");
124
+ archiveEntry(root, target, ctx.stdout);
125
+ if (mode.kind === "shared")
126
+ ctx.stdout.write(`Publish with: ${ctx.form} sync\n`);
127
+ return 0;
128
+ }
129
+ function resolveArchiveTarget(ctx, args, usage) {
130
+ const { values, positionals } = parseCommandArgs(usage, () => parseArgs({
131
+ args,
132
+ options: { help: { type: "boolean", short: "h", default: false } },
133
+ strict: true,
134
+ allowPositionals: true,
135
+ }));
136
+ if (values.help) {
137
+ ctx.stdout.write(usage);
138
+ return;
139
+ }
140
+ if (positionals.length !== 1)
141
+ throw new CliError(usage.trimEnd());
142
+ const argument = positionals[0];
143
+ const plansDir = join(ctx.cwd, ".plans");
144
+ const target = isPathArgument(argument) ? resolve(ctx.cwd, argument) : join(plansDir, argument);
145
+ const stats = statSync(target, { throwIfNoEntry: false });
146
+ if (!stats?.isDirectory() || realpathSync(dirname(target)) !== realpathSync(plansDir))
147
+ throw new CliError(`${argument} must be an existing directory directly under .plans.`);
148
+ if (basename(target).startsWith("_"))
149
+ throw new CliError(`${argument}: names starting with _ are not tickets.`);
150
+ return target;
151
+ }
152
+ function isPathArgument(argument) {
153
+ return argument.includes("/") || argument.includes("\\");
154
+ }
@@ -0,0 +1,2 @@
1
+ import type { CommandContext } from "../context.js";
2
+ export declare function runSetup(ctx: CommandContext, args: string[]): number;
@@ -0,0 +1,254 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmdirSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { parseArgs } from "node:util";
4
+ import { CliError } from "../cli-error.js";
5
+ import { assertMainWorktreeRoot, gitOutputOrUndefined, gitSucceeds } from "../git.js";
6
+ import { normalizeRemoteUrl } from "../overlay.js";
7
+ import { parseCommandArgs } from "../parse-args.js";
8
+ import { linkPlans } from "../plans/link.js";
9
+ import { PROJECT_CONFIG_FILENAME, readProjectConfig, validateProjectConfig, } from "../project-config.js";
10
+ import { installStubSkills } from "../skills.js";
11
+ import { defaultCliRange } from "../version-guard.js";
12
+ const ADOPT_FILES = ["AGENTS.md", "DEVELOPERS.md", "docs"];
13
+ export function runSetup(ctx, args) {
14
+ const usage = setupUsage(ctx);
15
+ const options = parseSetupArgs(ctx, args, usage);
16
+ if (options === undefined)
17
+ return 0;
18
+ if (options.overlay)
19
+ return runOverlaySetup(ctx, options);
20
+ if (options.adopt)
21
+ return runAdopt(ctx);
22
+ return runDefaultSetup(ctx, options);
23
+ }
24
+ function setupUsage(ctx) {
25
+ return `Usage:
26
+ ${ctx.form} setup [--ticket-pattern <regex>] [--plans-folder <name>] [--port-range <first>-<last>] [--agent <name>]...
27
+ ${ctx.form} setup --overlay [--plans-folder <name>] [--ticket-pattern <regex>] [--port-range <first>-<last>]
28
+ ${ctx.form} setup --adopt
29
+ `;
30
+ }
31
+ function parseSetupArgs(ctx, args, usage) {
32
+ const { values } = parseCommandArgs(usage, () => parseArgs({
33
+ args,
34
+ options: {
35
+ "ticket-pattern": { type: "string" },
36
+ "plans-folder": { type: "string" },
37
+ "port-range": { type: "string" },
38
+ agent: { type: "string", multiple: true, default: [] },
39
+ overlay: { type: "boolean", default: false },
40
+ adopt: { type: "boolean", default: false },
41
+ help: { type: "boolean", short: "h", default: false },
42
+ },
43
+ strict: true,
44
+ }));
45
+ if (values.help) {
46
+ ctx.stdout.write(usage);
47
+ return;
48
+ }
49
+ if (values.overlay && values.adopt)
50
+ throw new CliError(`--overlay and --adopt are mutually exclusive.\n\n${usage}`);
51
+ if ((values.overlay || values.adopt) && values.agent.length > 0)
52
+ throw new CliError(`--agent is available only in the default setup mode.\n\n${usage}`);
53
+ const portRange = values["port-range"] === undefined ? undefined : parsePortRange(values["port-range"], usage);
54
+ const options = {
55
+ ticketPattern: values["ticket-pattern"],
56
+ plansFolder: values["plans-folder"],
57
+ portRange,
58
+ agents: values.agent,
59
+ overlay: values.overlay,
60
+ adopt: values.adopt,
61
+ };
62
+ validateSetupOptions(options);
63
+ return options;
64
+ }
65
+ function parsePortRange(value, usage) {
66
+ const match = /^(\d+)-(\d+)$/.exec(value);
67
+ if (!match)
68
+ throw new CliError(`--port-range must be <first>-<last>.\n\n${usage}`);
69
+ return { first: Number(match[1]), last: Number(match[2]) };
70
+ }
71
+ function validateSetupOptions(options) {
72
+ validateProjectConfig({
73
+ schemaVersion: 1,
74
+ ...(options.ticketPattern === undefined ? {} : { ticketPattern: options.ticketPattern }),
75
+ ...(options.plansFolder === undefined ? {} : { plans: { folder: options.plansFolder } }),
76
+ ...(options.portRange === undefined ? {} : { portRange: options.portRange }),
77
+ }, PROJECT_CONFIG_FILENAME);
78
+ }
79
+ function runDefaultSetup(ctx, options) {
80
+ assertMainWorktreeRoot(ctx.cwd);
81
+ setupProjectConfig(ctx, options);
82
+ setupPlansDirectory(ctx);
83
+ installStubSkills(ctx, options.agents);
84
+ ctx.stdout.write("Installed the AlignFirst skills globally.\n");
85
+ setupReadme(ctx);
86
+ return 0;
87
+ }
88
+ function setupProjectConfig(ctx, options) {
89
+ const path = join(ctx.cwd, PROJECT_CONFIG_FILENAME);
90
+ if (existsSync(path)) {
91
+ readProjectConfig(ctx.cwd);
92
+ ctx.stdout.write(`${PROJECT_CONFIG_FILENAME} is valid.\n`);
93
+ if (hasProjectOptions(options))
94
+ throw new CliError(`${PROJECT_CONFIG_FILENAME} exists; edit it instead of passing options.`);
95
+ return;
96
+ }
97
+ const cli = defaultCliRange(ctx.version);
98
+ const config = validateProjectConfig({
99
+ schemaVersion: 1,
100
+ cli,
101
+ ...(options.ticketPattern === undefined ? {} : { ticketPattern: options.ticketPattern }),
102
+ ...(options.plansFolder === undefined ? {} : { plans: { folder: options.plansFolder } }),
103
+ ...(options.portRange === undefined ? {} : { portRange: options.portRange }),
104
+ }, PROJECT_CONFIG_FILENAME);
105
+ writeJson(path, config);
106
+ ctx.stdout.write(`Created ${PROJECT_CONFIG_FILENAME} (cli ${cli})\n`);
107
+ }
108
+ function hasProjectOptions(options) {
109
+ return (options.ticketPattern !== undefined ||
110
+ options.plansFolder !== undefined ||
111
+ options.portRange !== undefined);
112
+ }
113
+ function setupPlansDirectory(ctx) {
114
+ const plansPath = join(ctx.cwd, ".plans");
115
+ if (!existsSync(plansPath)) {
116
+ mkdirSync(plansPath);
117
+ ctx.stdout.write("Created .plans/\n");
118
+ }
119
+ if (gitSucceeds(ctx.cwd, "check-ignore", "-q", ".plans"))
120
+ return;
121
+ appendLine(join(ctx.cwd, ".gitignore"), ".plans");
122
+ ctx.stdout.write("Added .plans to .gitignore.\n");
123
+ }
124
+ function appendLine(path, line) {
125
+ const content = existsSync(path) ? readFileSync(path, "utf-8") : "";
126
+ const separator = content === "" || content.endsWith("\n") ? "" : "\n";
127
+ appendFileSync(path, `${separator}${line}\n`);
128
+ }
129
+ function setupReadme(ctx) {
130
+ const path = join(ctx.cwd, "README.md");
131
+ if (!existsSync(path))
132
+ return;
133
+ const content = readFileSync(path, "utf-8");
134
+ if (/alignfirst/i.test(content))
135
+ return;
136
+ appendFileSync(path, "\n## Prerequisites\n\nInstall the AlignFirst CLI: `npm install -g alignfirst`.\n");
137
+ ctx.stdout.write("Added the CLI prerequisite to README.md.\n");
138
+ }
139
+ function runOverlaySetup(ctx, options) {
140
+ const overlaysDir = resolveOverlaysDir(ctx);
141
+ assertMainWorktreeRoot(ctx.cwd);
142
+ const projectPath = realpathSync(ctx.cwd);
143
+ const name = options.plansFolder ?? basename(projectPath);
144
+ const overlayDir = join(overlaysDir, name, "_project");
145
+ if (existsSync(overlayDir))
146
+ throw new CliError(`${overlayDir} already exists.`);
147
+ const config = buildOverlayConfig(ctx, options, projectPath);
148
+ mkdirSync(overlayDir, { recursive: true });
149
+ writeJson(join(overlayDir, PROJECT_CONFIG_FILENAME), config);
150
+ ctx.stdout.write(`Created overlay: ${overlayDir}\n`);
151
+ setupOverlayPlans(ctx, overlaysDir, name);
152
+ setupGitExclude(ctx);
153
+ return 0;
154
+ }
155
+ function resolveOverlaysDir(ctx) {
156
+ const value = ctx.env.ALIGNFIRST_OVERLAYS;
157
+ if (value === undefined || value === "")
158
+ throw new CliError("ALIGNFIRST_OVERLAYS is not set.");
159
+ return value.startsWith("~/") ? join(ctx.home, value.slice(2)) : value;
160
+ }
161
+ function buildOverlayConfig(ctx, options, projectPath) {
162
+ const origin = gitOutputOrUndefined(ctx.cwd, "remote", "get-url", "origin");
163
+ return validateProjectConfig({
164
+ schemaVersion: 1,
165
+ project: {
166
+ ...(origin === undefined || origin === "" ? {} : { remote: normalizeRemoteUrl(origin) }),
167
+ paths: [projectPath],
168
+ },
169
+ ...(options.ticketPattern === undefined ? {} : { ticketPattern: options.ticketPattern }),
170
+ ...(options.plansFolder === undefined ? {} : { plans: { folder: options.plansFolder } }),
171
+ ...(options.portRange === undefined ? {} : { portRange: options.portRange }),
172
+ }, PROJECT_CONFIG_FILENAME);
173
+ }
174
+ function setupOverlayPlans(ctx, overlaysDir, name) {
175
+ if (gitSucceeds(overlaysDir, "rev-parse", "--git-dir")) {
176
+ linkPlans(ctx, join(overlaysDir, name));
177
+ return;
178
+ }
179
+ const plansPath = join(ctx.cwd, ".plans");
180
+ if (!existsSync(plansPath))
181
+ mkdirSync(plansPath);
182
+ ctx.stdout.write("Using a local .plans directory.\n");
183
+ }
184
+ function setupGitExclude(ctx) {
185
+ if (gitSucceeds(ctx.cwd, "check-ignore", "-q", ".plans"))
186
+ return;
187
+ appendLine(join(ctx.cwd, ".git", "info", "exclude"), ".plans");
188
+ ctx.stdout.write("Added .plans to .git/info/exclude.\n");
189
+ }
190
+ function runAdopt(ctx) {
191
+ const overlay = ctx.overlay;
192
+ if (overlay === undefined) {
193
+ const value = ctx.env.ALIGNFIRST_OVERLAYS;
194
+ throw new CliError(`No overlay matches this repository (ALIGNFIRST_OVERLAYS=${value === undefined || value === "" ? "unset" : value}).`);
195
+ }
196
+ adoptConfig(ctx, overlay.dir, overlay.config);
197
+ const agentsConflict = adoptFiles(ctx, overlay.dir);
198
+ removePlansExclude(ctx);
199
+ if (readdirSync(overlay.dir).length === 0)
200
+ rmdirSync(overlay.dir);
201
+ else
202
+ ctx.stdout.write(`Overlay remains: ${overlay.dir}\n`);
203
+ ctx.stdout.write("Next: add .plans to .gitignore.\n");
204
+ if (agentsConflict)
205
+ ctx.stdout.write("Next: merge the overlay AGENTS.md conventions by hand.\n");
206
+ return 0;
207
+ }
208
+ function adoptConfig(ctx, overlayDir, config) {
209
+ const name = PROJECT_CONFIG_FILENAME;
210
+ const source = join(overlayDir, name);
211
+ const target = join(ctx.cwd, name);
212
+ if (existsSync(target)) {
213
+ ctx.stdout.write(`kept in the overlay: ${name} (the root has its own)\n`);
214
+ return;
215
+ }
216
+ const rootConfig = structuredClone(config);
217
+ delete rootConfig.project;
218
+ writeJson(target, validateProjectConfig(rootConfig, name));
219
+ unlinkSync(source);
220
+ ctx.stdout.write(`Adopted ${name}.\n`);
221
+ }
222
+ function adoptFiles(ctx, overlayDir) {
223
+ let agentsConflict = false;
224
+ for (const name of ADOPT_FILES) {
225
+ const source = join(overlayDir, name);
226
+ if (!existsSync(source))
227
+ continue;
228
+ const target = join(ctx.cwd, name);
229
+ if (existsSync(target)) {
230
+ ctx.stdout.write(`kept in the overlay: ${name} (the root has its own)\n`);
231
+ if (name === "AGENTS.md")
232
+ agentsConflict = true;
233
+ continue;
234
+ }
235
+ renameSync(source, target);
236
+ ctx.stdout.write(`Adopted ${name}.\n`);
237
+ }
238
+ return agentsConflict;
239
+ }
240
+ function removePlansExclude(ctx) {
241
+ const path = join(ctx.cwd, ".git", "info", "exclude");
242
+ if (!existsSync(path))
243
+ return;
244
+ const content = readFileSync(path, "utf-8");
245
+ const lines = content.split(/\r?\n/);
246
+ const filtered = lines.filter((line) => line.trim() !== ".plans");
247
+ if (filtered.length === lines.length)
248
+ return;
249
+ writeFileSync(path, filtered.join("\n"));
250
+ ctx.stdout.write("Removed .plans from .git/info/exclude.\n");
251
+ }
252
+ function writeJson(path, value) {
253
+ writeFileSync(path, `${JSON.stringify(value, undefined, 2)}\n`);
254
+ }
@@ -0,0 +1,2 @@
1
+ import type { CommandContext } from "../context.js";
2
+ export declare function runSync(ctx: CommandContext, args: string[]): number;
@@ -0,0 +1,63 @@
1
+ import { join } from "node:path";
2
+ import { parseArgs } from "node:util";
3
+ import { git, gitOutput, gitSucceeds } from "../git.js";
4
+ import { parseCommandArgs } from "../parse-args.js";
5
+ import { archiveThresholdDays, autoArchive } from "../plans/archive.js";
6
+ import { resolvePlansMode } from "../plans/mode.js";
7
+ export function runSync(ctx, args) {
8
+ const usage = `Usage: ${ctx.form} sync [--auto-archive]\n`;
9
+ const options = parseSyncArgs(ctx, args, usage);
10
+ if (options === undefined)
11
+ return 0;
12
+ const thresholdDays = options.autoArchive ? archiveThresholdDays(ctx.env) : undefined;
13
+ const mode = resolvePlansMode(ctx.cwd, ctx.form);
14
+ const plansDir = join(ctx.cwd, ".plans");
15
+ if (mode.kind === "local") {
16
+ if (thresholdDays !== undefined)
17
+ autoArchive(plansDir, thresholdDays, ctx.stdout);
18
+ ctx.stdout.write("(local plans mode, nothing to sync)\n");
19
+ return 0;
20
+ }
21
+ const repoDir = mode.repoToplevel;
22
+ if (hasHead(repoDir))
23
+ git(repoDir, "pull", "--rebase", "--autostash");
24
+ if (thresholdDays !== undefined)
25
+ autoArchive(plansDir, thresholdDays, ctx.stdout);
26
+ git(repoDir, "add", "-A");
27
+ if (hasStagedChanges(repoDir))
28
+ git(repoDir, "commit", "--quiet", "-m", "sync");
29
+ if (hasHead(repoDir) && hasCommitsToSend(repoDir)) {
30
+ git(repoDir, "push", "--quiet", "-u", "origin", "HEAD");
31
+ ctx.stdout.write("Plans synchronized: local changes sent.\n");
32
+ }
33
+ else {
34
+ ctx.stdout.write("Plans synchronized: nothing to send.\n");
35
+ }
36
+ return 0;
37
+ }
38
+ function parseSyncArgs(ctx, args, usage) {
39
+ const { values } = parseCommandArgs(usage, () => parseArgs({
40
+ args,
41
+ options: {
42
+ "auto-archive": { type: "boolean", default: false },
43
+ help: { type: "boolean", short: "h", default: false },
44
+ },
45
+ strict: true,
46
+ }));
47
+ if (values.help) {
48
+ ctx.stdout.write(usage);
49
+ return;
50
+ }
51
+ return { autoArchive: values["auto-archive"] };
52
+ }
53
+ function hasHead(dir) {
54
+ return gitSucceeds(dir, "rev-parse", "--verify", "-q", "HEAD");
55
+ }
56
+ function hasStagedChanges(dir) {
57
+ return !gitSucceeds(dir, "diff", "--cached", "--quiet");
58
+ }
59
+ function hasCommitsToSend(dir) {
60
+ if (!gitSucceeds(dir, "rev-parse", "--verify", "-q", "@{u}"))
61
+ return true;
62
+ return gitOutput(dir, "rev-list", "--count", "@{u}..HEAD") !== "0";
63
+ }
@@ -0,0 +1,2 @@
1
+ import type { CommandContext } from "../context.js";
2
+ export declare function runTicket(ctx: CommandContext, args: string[]): number;
@@ -0,0 +1,119 @@
1
+ import { join, relative } from "node:path";
2
+ import { parseArgs } from "node:util";
3
+ import { CliError } from "../cli-error.js";
4
+ import { parseCommandArgs } from "../parse-args.js";
5
+ import { assertPlansGate } from "../plans/layout.js";
6
+ import { deduceTicketFromBranch, nextFileName, peekSideTicket, reserveSideTicket, resolveTicketDir, validateTicketId, } from "../plans/ticket.js";
7
+ const USAGE = `Usage:
8
+ {{FORM}} ticket [<id>] [--next <filename>] [--new-cycle] [--json] [--dry-run]
9
+ {{FORM}} ticket --side [--json] [--dry-run]
10
+ `;
11
+ export function runTicket(ctx, args) {
12
+ assertPlansGate(ctx.cwd, ctx.form);
13
+ const usage = renderUsage(ctx);
14
+ const parsed = parseTicketArgs(ctx, args, usage);
15
+ if (parsed === undefined)
16
+ return 0;
17
+ const result = resolveTicket(ctx, parsed);
18
+ const next = parsed.next === undefined ? undefined : nextFileName(result.dir, parsed.next, parsed.newCycle);
19
+ if (parsed.json)
20
+ ctx.stdout.write(`${JSON.stringify(jsonReport(ctx, parsed, result, next), undefined, 2)}\n`);
21
+ else
22
+ ctx.stdout.write(renderReport(ctx, parsed, result, next));
23
+ return 0;
24
+ }
25
+ function renderUsage(ctx) {
26
+ return USAGE.replaceAll("{{FORM}}", ctx.form);
27
+ }
28
+ function parseTicketArgs(ctx, args, usage) {
29
+ const { values, positionals } = parseCommandArgs(usage, () => parseArgs({
30
+ args,
31
+ options: {
32
+ next: { type: "string" },
33
+ "new-cycle": { type: "boolean", default: false },
34
+ json: { type: "boolean", default: false },
35
+ "dry-run": { type: "boolean", default: false },
36
+ side: { type: "boolean", default: false },
37
+ help: { type: "boolean", short: "h", default: false },
38
+ },
39
+ strict: true,
40
+ allowPositionals: true,
41
+ }));
42
+ if (values.help) {
43
+ ctx.stdout.write(usage);
44
+ return;
45
+ }
46
+ if (positionals.length > 1)
47
+ throw new CliError(`Expected at most one ticket id.\n\n${usage}`);
48
+ if (values.side && positionals.length > 0)
49
+ throw new CliError(`A ticket id cannot be combined with --side.\n\n${usage}`);
50
+ if (values["new-cycle"] && values.next === undefined)
51
+ throw new CliError(`--new-cycle requires --next.\n\n${usage}`);
52
+ const resolution = resolveTicketId(ctx, positionals[0], values.side, values["dry-run"]);
53
+ return {
54
+ ...resolution,
55
+ next: values.next,
56
+ newCycle: values["new-cycle"],
57
+ json: values.json,
58
+ dryRun: values["dry-run"],
59
+ side: values.side,
60
+ };
61
+ }
62
+ function resolveTicketId(ctx, positional, side, dryRun) {
63
+ const pattern = ctx.projectConfig?.config.ticketPattern;
64
+ if (positional !== undefined) {
65
+ validateTicketId(positional, pattern);
66
+ return { id: positional };
67
+ }
68
+ if (side)
69
+ return { id: dryRun ? peekSideTicket(ctx.cwd) : reserveSideTicket(ctx.cwd) };
70
+ if (pattern === undefined)
71
+ throw new CliError("No ticket id given and .alignfirst.json has no ticketPattern: pass the id.");
72
+ const deduced = deduceTicketFromBranch(ctx.cwd, pattern);
73
+ validateTicketId(deduced.id, pattern);
74
+ return deduced;
75
+ }
76
+ function resolveTicket(ctx, options) {
77
+ if (options.side && !options.dryRun)
78
+ return {
79
+ id: options.id,
80
+ dir: join(ctx.cwd, ".plans", options.id),
81
+ state: "created",
82
+ entries: [],
83
+ };
84
+ return resolveTicketDir(ctx.cwd, options.id, { dryRun: options.dryRun });
85
+ }
86
+ function jsonReport(ctx, options, result, next) {
87
+ return {
88
+ id: result.id,
89
+ dir: relative(ctx.cwd, result.dir),
90
+ state: result.state,
91
+ ...(options.branch === undefined ? {} : { branch: options.branch }),
92
+ entries: result.entries,
93
+ ...(next === undefined ? {} : { next: relative(ctx.cwd, join(result.dir, next)) }),
94
+ };
95
+ }
96
+ function renderReport(ctx, options, result, next) {
97
+ const reservation = options.side && options.dryRun ? " (would be reserved)" : "";
98
+ const deduction = options.branch === undefined ? "" : ` (deduced from branch ${options.branch})`;
99
+ const directoryState = renderDirectoryState(result.state, options.dryRun);
100
+ const directory = `${relative(ctx.cwd, result.dir)}/`;
101
+ const lines = [
102
+ `Ticket ${result.id}${reservation}${deduction}`,
103
+ `Directory: ${directory}${directoryState}`,
104
+ ];
105
+ if (result.entries.length === 0)
106
+ lines.push("Entries: (none)");
107
+ else
108
+ lines.push("Entries:", ...result.entries.map((entry) => ` ${entry}`));
109
+ if (next !== undefined)
110
+ lines.push(`Next file: ${relative(ctx.cwd, join(result.dir, next))}`);
111
+ return `${lines.join("\n")}\n`;
112
+ }
113
+ function renderDirectoryState(state, dryRun) {
114
+ if (state === "existing")
115
+ return "";
116
+ if (state === "created")
117
+ return dryRun ? " (would be created)" : " (created)";
118
+ return dryRun ? " (would be restored from _archives)" : " (restored from _archives)";
119
+ }
@@ -0,0 +1,15 @@
1
+ import type { Overlay, ResolvedProjectConfig } from "./overlay.js";
2
+ export interface Output {
3
+ write(text: string): void;
4
+ }
5
+ export interface CommandContext {
6
+ cwd: string;
7
+ env: NodeJS.ProcessEnv;
8
+ home: string;
9
+ stdout: Output;
10
+ stderr: Output;
11
+ form: string;
12
+ version: string;
13
+ projectConfig?: ResolvedProjectConfig;
14
+ overlay?: Overlay;
15
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ export declare function errorMessage(error: unknown): string;
2
+ export declare function isNodeError(error: unknown): error is NodeJS.ErrnoException;
package/dist/errors.js ADDED
@@ -0,0 +1,6 @@
1
+ export function errorMessage(error) {
2
+ return error instanceof Error ? error.message : String(error);
3
+ }
4
+ export function isNodeError(error) {
5
+ return error instanceof Error && "code" in error;
6
+ }
@@ -0,0 +1 @@
1
+ export declare function findExecutable(env: NodeJS.ProcessEnv, name: string): string | undefined;
@@ -0,0 +1,24 @@
1
+ import { accessSync, constants, statSync } from "node:fs";
2
+ import { delimiter, join } from "node:path";
3
+ export function findExecutable(env, name) {
4
+ const path = env.PATH;
5
+ if (path === undefined || path === "")
6
+ return;
7
+ for (const dir of path.split(delimiter)) {
8
+ const candidate = join(dir, name);
9
+ if (isExecutableFile(candidate))
10
+ return candidate;
11
+ }
12
+ return;
13
+ }
14
+ function isExecutableFile(path) {
15
+ try {
16
+ if (!statSync(path).isFile())
17
+ return false;
18
+ accessSync(path, constants.X_OK);
19
+ return true;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
package/dist/git.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export declare function git(dir: string, ...args: string[]): void;
2
+ export declare function assertMainWorktreeRoot(cwd: string): void;
3
+ export declare function gitOutput(dir: string, ...args: string[]): string;
4
+ export declare function gitOutputOrUndefined(dir: string, ...args: string[]): string | undefined;
5
+ export declare function gitSucceeds(dir: string, ...args: string[]): boolean;