alignfirst 0.1.0-beta.6 → 0.1.0-beta.7

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.
@@ -5,6 +5,7 @@ import { CliError } from "../cli-error.js";
5
5
  import { assertMainWorktreeRoot } from "../git.js";
6
6
  import { parseBareCommandArgs, parseCommandArgs } from "../parse-args.js";
7
7
  import { archiveEntry, archiveThresholdDays, autoArchive } from "../plans/archive.js";
8
+ import { isTicketName } from "../plans/layout.js";
8
9
  import { linkPlans } from "../plans/link.js";
9
10
  import { resolvePlansMode } from "../plans/mode.js";
10
11
  import { findStoppedRebase, renderStoppedRebase } from "../plans/rebase.js";
@@ -156,9 +157,10 @@ function resolveArchiveTarget(ctx, args, usage) {
156
157
  const stats = statSync(target, { throwIfNoEntry: false });
157
158
  if (!stats?.isDirectory() || realpathSync(dirname(target)) !== realpathSync(plansDir))
158
159
  throw new CliError(`${argument} must be an existing directory directly under .plans.`);
159
- if (basename(target).startsWith("_"))
160
+ const name = basename(target);
161
+ if (!isTicketName(name))
160
162
  throw new CliError(`${argument}: names starting with _ are not tickets.`);
161
- return target;
163
+ return join(plansDir, name);
162
164
  }
163
165
  function isPathArgument(argument) {
164
166
  return argument.includes("/") || argument.includes("\\");
@@ -16,11 +16,11 @@ Files over 64 KiB are listed without content. Above 30 KiB of output, only the e
16
16
  list is printed.
17
17
  `;
18
18
  export function runTicket(ctx, args) {
19
- assertPlansGate(ctx.cwd, ctx.form);
20
19
  const usage = renderUsage(ctx);
21
20
  const parsed = parseTicketArgs(ctx, args, usage);
22
21
  if (parsed === undefined)
23
22
  return 0;
23
+ assertPlansGate(ctx.cwd, ctx.form);
24
24
  const result = resolveTicket(ctx, parsed);
25
25
  if (parsed.catchup) {
26
26
  ctx.stdout.write(renderCatchup(ctx.cwd, result, renderReport(ctx, parsed, result)));
@@ -1,6 +1,7 @@
1
1
  import { existsSync, lstatSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { renderDefaultBranchLine, resolveDefaultBranch } from "./default-branch.js";
4
+ import { errorMessage } from "./errors.js";
4
5
  import { gitOutputOrUndefined, gitSucceeds } from "./git.js";
5
6
  import { resolvePlansMode } from "./plans/mode.js";
6
7
  export function renderConventions(ctx) {
@@ -59,8 +60,7 @@ function renderPlans(ctx) {
59
60
  return `${base}${archival}`;
60
61
  }
61
62
  catch (error) {
62
- const message = error instanceof Error ? error.message : String(error);
63
- return `Plans: ${message.split("\n", 1)[0]}`;
63
+ return `Plans: ${errorMessage(error).split("\n", 1)[0]}`;
64
64
  }
65
65
  }
66
66
  function plansEntryExists(cwd) {
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join, relative } from "node:path";
3
3
  import { CliError } from "../cli-error.js";
4
+ import { errorMessage } from "../errors.js";
4
5
  import { formatLocalTimestamp, formatSize } from "../format.js";
5
6
  const MAX_FILE_BYTES = 64 * 1024;
6
7
  const MAX_OUTPUT_BYTES = 30 * 1024;
@@ -49,7 +50,6 @@ function readBody(cwd, file) {
49
50
  return readFileSync(join(cwd, file.path), "utf-8");
50
51
  }
51
52
  catch (error) {
52
- const detail = error instanceof Error ? error.message : String(error);
53
- throw new CliError(`Cannot load catchup file ${file.path}: ${detail}`);
53
+ throw new CliError(`Cannot load catchup file ${file.path}: ${errorMessage(error)}`);
54
54
  }
55
55
  }
@@ -1,9 +1,11 @@
1
+ import { type Stats } from "node:fs";
1
2
  import { CliError } from "../cli-error.js";
2
3
  export declare const PLANS_DIR = ".plans";
3
4
  export declare const ARCHIVES_DIR = "_archives";
4
5
  export declare function isTicketName(name: string): boolean;
5
6
  export declare function plansDir(cwd: string): string;
6
7
  export declare function archivesDir(cwd: string): string;
7
- export declare function assertPlansGate(cwd: string, form: string): void;
8
+ /** Returns the lstat of `.plans`, so callers can tell a symlink from a directory. */
9
+ export declare function assertPlansGate(cwd: string, form: string): Stats;
8
10
  export declare function missingPlansMessage(form: string): string;
9
11
  export declare function missingPlansError(form: string): CliError;
@@ -1,4 +1,4 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, lstatSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { CliError } from "../cli-error.js";
4
4
  export const PLANS_DIR = ".plans";
@@ -12,10 +12,17 @@ export function plansDir(cwd) {
12
12
  export function archivesDir(cwd) {
13
13
  return join(plansDir(cwd), ARCHIVES_DIR);
14
14
  }
15
+ /** Returns the lstat of `.plans`, so callers can tell a symlink from a directory. */
15
16
  export function assertPlansGate(cwd, form) {
16
- if (existsSync(plansDir(cwd)))
17
- return;
18
- throw missingPlansError(form);
17
+ const path = plansDir(cwd);
18
+ const stats = lstatSync(path, { throwIfNoEntry: false });
19
+ if (!stats)
20
+ throw missingPlansError(form);
21
+ if (stats.isSymbolicLink() && !existsSync(path))
22
+ throw new CliError(`The .plans symlink is broken. Re-run ${form} plans setup with the clone location.`);
23
+ if (!statSync(path).isDirectory())
24
+ throw new CliError(`.plans is not a directory. Remove it, then run ${form} plans setup (see the project documentation).`);
25
+ return stats;
19
26
  }
20
27
  export function missingPlansMessage(form) {
21
28
  return `No .plans/ directory in the current directory.\nLocal plans: mkdir .plans && echo .plans >> .gitignore\nTeam plans: ${form} plans setup <clone-dir>`;
@@ -1,17 +1,11 @@
1
- import { existsSync, lstatSync, realpathSync, statSync } from "node:fs";
1
+ import { realpathSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { CliError } from "../cli-error.js";
4
4
  import { gitOutput } from "../git.js";
5
- import { missingPlansError } from "./layout.js";
5
+ import { assertPlansGate } from "./layout.js";
6
6
  export function resolvePlansMode(cwd, form) {
7
7
  const plansPath = join(cwd, ".plans");
8
- const stats = lstatSync(plansPath, { throwIfNoEntry: false });
9
- if (!stats)
10
- throw missingPlansError(form);
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).`);
8
+ const stats = assertPlansGate(cwd, form);
15
9
  if (plansRepositoryId(plansPath, stats.isSymbolicLink(), form) === repositoryId(cwd))
16
10
  return { kind: "local" };
17
11
  return { kind: "shared", repoToplevel: gitOutput(plansPath, "rev-parse", "--show-toplevel") };
@@ -105,7 +105,7 @@ export function isPathSafeTicketId(id) {
105
105
  return id !== "." && !id.includes("..") && PATH_SAFE_TICKET.test(id);
106
106
  }
107
107
  export function validateTicketId(id, pattern) {
108
- if (!isPathSafeTicketId(id))
108
+ if (!isPathSafeTicketId(id) || !isTicketName(id))
109
109
  throw new CliError(`Invalid ticket id: ${id}`);
110
110
  if (pattern !== undefined && !new RegExp(pattern).test(id) && !SIDE_TICKET.test(id))
111
111
  throw new CliError(`Ticket id "${id}" does not match ticketIdPattern "${pattern}".`);
@@ -75,6 +75,26 @@ function assertValidPattern(pattern, label) {
75
75
  catch (error) {
76
76
  throw invalidConfig(label, `ticketIdPattern is not a valid regular expression: ${errorMessage(error)}`);
77
77
  }
78
+ if (hasInnerAnchor(pattern))
79
+ throw invalidConfig(label, "ticketIdPattern must be one anchored expression; group alternatives: ^(ABC|XYZ)-\\d+$");
80
+ }
81
+ /** Branch detection unanchors the pattern by trimming its ends, so inner anchors never match. */
82
+ function hasInnerAnchor(pattern) {
83
+ let inClass = false;
84
+ for (let i = 0; i < pattern.length; ++i) {
85
+ const char = pattern[i];
86
+ if (char === "\\")
87
+ ++i;
88
+ else if (inClass)
89
+ inClass = char !== "]";
90
+ else if (char === "[")
91
+ inClass = true;
92
+ else if (char === "^" && i !== 0)
93
+ return true;
94
+ else if (char === "$" && i !== pattern.length - 1)
95
+ return true;
96
+ }
97
+ return false;
78
98
  }
79
99
  function assertValidPortRange(range, label) {
80
100
  if (range.first > range.last)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alignfirst",
3
- "version": "0.1.0-beta.6",
3
+ "version": "0.1.0-beta.7",
4
4
  "license": "CC0-1.0",
5
5
  "author": "Thomas MUR",
6
6
  "description": "The AlignFirst CLI: protocols, plans and docs in one command.",