Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/thin-rings-behave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/intent': patch
---

Refactored the CLI to use `cac`, replacing the previous hand-rolled parsing and dispatch logic with a more structured command system.

This update also fixes monorepo workflow generation behavior related to `setup-github-actions`, improving repo/package fallback handling and ensuring generated workflow watch paths are monorepo-aware.
5 changes: 4 additions & 1 deletion docs/cli/intent-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ npx @tanstack/intent@latest setup-github-actions
- Preserves existing indentation
- `setup-github-actions`
- Copies templates from `@tanstack/intent/meta/templates/workflows` to `.github/workflows`
- Applies variable substitution for `PACKAGE_NAME`, `REPO`, `DOCS_PATH`, `SRC_PATH`
- Applies variable substitution (`PACKAGE_NAME`, `PACKAGE_LABEL`, `PAYLOAD_PACKAGE`, `REPO`, `DOCS_PATH`, `SRC_PATH`, `WATCH_PATHS`)
- Detects the workspace root in monorepos and writes repo-level workflows there
- Generates monorepo-aware watch paths for package `src/` and docs directories
- Skips files that already exist at destination

## Required `files` entries
Expand All @@ -42,6 +44,7 @@ npx @tanstack/intent@latest setup-github-actions
## Notes

- `setup-github-actions` skips existing files
- In monorepos, run `setup-github-actions` from either the repo root or a package directory; Intent writes workflows to the workspace root

## Related

Expand Down
2 changes: 2 additions & 0 deletions packages/intent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"meta"
],
"dependencies": {
"cac": "^6.7.14",
"yaml": "^2.7.0"
},
"devDependencies": {
Expand All @@ -37,6 +38,7 @@
"scripts": {
"prepack": "npm run build",
"build": "tsdown src/index.ts src/cli.ts src/setup.ts src/intent-library.ts src/library-scanner.ts --format esm --dts",
"test:smoke": "pnpm run build && node dist/cli.mjs --help > /dev/null",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use a cross-platform smoke test command.

Line 41 uses > /dev/null, which is POSIX-specific and fails in Windows shells. Prefer keeping output or using a Node-based suppression strategy.

Suggested minimal fix
-    "test:smoke": "pnpm run build && node dist/cli.mjs --help > /dev/null",
+    "test:smoke": "pnpm run build && node dist/cli.mjs --help",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"test:smoke": "pnpm run build && node dist/cli.mjs --help > /dev/null",
"test:smoke": "pnpm run build && node dist/cli.mjs --help",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/intent/package.json` at line 41, The smoke-test script uses POSIX
redirection ("> /dev/null") which breaks on Windows; update the "test:smoke" npm
script to use a cross-platform Node-based suppression instead of shell
redirection. Replace the current value of the "test:smoke" script with a Node
one-liner that runs "dist/cli.mjs --help" via child_process (e.g., spawnSync or
execSync) with stdio set to "ignore", so the command exits successfully on all
platforms while suppressing output; locate the "test:smoke" script entry in
package.json and update it accordingly.

"test:lib": "vitest run --exclude 'tests/integration/**'",
"test:integration": "vitest run tests/integration/",
"test:types": "tsc --noEmit"
Expand Down
18 changes: 18 additions & 0 deletions packages/intent/src/cli-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const CLI_FAILURE = Symbol('CliFailure')

export type CliFailure = {
readonly [CLI_FAILURE]: true
message: string
exitCode: number
}

// Throws a structured CliFailure (not an Error) — this represents an expected
// user-facing failure, not an internal bug. Stack traces are intentionally
// omitted since these are anticipated exit paths (bad input, missing files, etc).
export function fail(message: string, exitCode = 1): never {
throw { [CLI_FAILURE]: true as const, message, exitCode } satisfies CliFailure
}

export function isCliFailure(value: unknown): value is CliFailure {
return !!value && typeof value === 'object' && CLI_FAILURE in value
}
86 changes: 86 additions & 0 deletions packages/intent/src/cli-support.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { existsSync, readFileSync } from 'node:fs'
import { dirname, join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { fail } from './cli-error.js'
import type { ScanResult, StalenessReport } from './types.js'

export function printWarnings(warnings: Array<string>): void {
if (warnings.length === 0) return

console.log('Warnings:')
for (const warning of warnings) {
console.log(` ⚠ ${warning}`)
}
}

export function getMetaDir(): string {
const thisDir = dirname(fileURLToPath(import.meta.url))
return join(thisDir, '..', 'meta')
}

export async function scanIntentsOrFail(): Promise<ScanResult> {
const { scanForIntents } = await import('./scanner.js')

try {
return scanForIntents()
} catch (err) {
fail(err instanceof Error ? err.message : String(err))
}
}

function readPackageName(root: string): string {
try {
const pkgJson = JSON.parse(
readFileSync(join(root, 'package.json'), 'utf8'),
) as {
name?: unknown
}
return typeof pkgJson.name === 'string'
? pkgJson.name
: relative(process.cwd(), root) || 'unknown'
} catch {
return relative(process.cwd(), root) || 'unknown'
}
}

export async function resolveStaleTargets(
targetDir?: string,
): Promise<{ reports: Array<StalenessReport> }> {
const resolvedRoot = targetDir
? join(process.cwd(), targetDir)
: process.cwd()
Comment on lines +49 to +51
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Node.js, what is the behavior difference between path.join(process.cwd(), absolutePath) and path.resolve(process.cwd(), absolutePath), especially when absolutePath starts with "/"?

💡 Result:

  • path.join(process.cwd(), absolutePath) always concatenates the segments and then normalizes. So if absolutePath starts with /, it’s treated like “a segment that happens to begin with a separator”, not as “reset to filesystem root”. Example (POSIX): path.join('/home/me/app', '/tmp')/home/me/app/tmp (the // becomes / during normalization). [1]

  • path.resolve(process.cwd(), absolutePath) resolves segments right-to-left and stops once it hits an absolute path; any earlier segments (like process.cwd()) are discarded. So if absolutePath starts with /, it wins. Example (POSIX): path.resolve('/home/me/app', '/tmp/file/')/tmp/file. [2]

So when absolutePath starts with /:

  • join(cwd, absolutePath) ≈ “force it under cwd
  • resolve(cwd, absolutePath) ≈ “use absolutePath as-is (absolute overrides)”

[1] (nodejs.org)
[2] (nodejs.org)

Citations:


🏁 Script executed:

cat -n packages/intent/src/cli-support.ts | head -50

Repository: TanStack/intent

Length of output: 1816


🏁 Script executed:

rg -A 2 "resolveStaleTargets" --type ts

Repository: TanStack/intent

Length of output: 1028


🏁 Script executed:

grep -A 10 -B 5 "targetDir" packages/intent/src/cli.ts | head -40

Repository: TanStack/intent

Length of output: 531


Use resolve() instead of join() for targetDir path resolution.

When targetDir is an absolute path (e.g., stale /tmp/project), join(process.cwd(), targetDir) concatenates the segments incorrectly. The / in the absolute path is treated as a regular path segment, not a filesystem root reset. Use resolve() instead, which correctly handles absolute paths by stopping resolution once an absolute component is encountered.

🔧 Proposed fix
-import { dirname, join, relative } from 'node:path'
+import { dirname, join, relative, resolve } from 'node:path'
@@
-  const resolvedRoot = targetDir
-    ? join(process.cwd(), targetDir)
-    : process.cwd()
+  const resolvedRoot = targetDir
+    ? resolve(process.cwd(), targetDir)
+    : process.cwd()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const resolvedRoot = targetDir
? join(process.cwd(), targetDir)
: process.cwd()
import { dirname, join, relative, resolve } from 'node:path'
// ... other code ...
const resolvedRoot = targetDir
? resolve(process.cwd(), targetDir)
: process.cwd()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/intent/src/cli-support.ts` around lines 40 - 42, The path resolution
for targetDir uses join(process.cwd(), targetDir) which incorrectly concatenates
when targetDir is absolute; change to use resolve(process.cwd(), targetDir) (or
simply resolve(targetDir) if appropriate) so absolute targetDir resets to the
filesystem root; update the variable resolvedRoot and any related tests/usages
to import/use path.resolve instead of path.join and ensure behavior remains the
same for relative inputs.

const { checkStaleness } = await import('./staleness.js')

if (existsSync(join(resolvedRoot, 'skills'))) {
return {
reports: [
await checkStaleness(resolvedRoot, readPackageName(resolvedRoot)),
],
}
}

const { findPackagesWithSkills, findWorkspaceRoot } =
await import('./setup.js')
const workspaceRoot = findWorkspaceRoot(resolvedRoot)
if (workspaceRoot) {
const packageDirs = findPackagesWithSkills(workspaceRoot)
if (packageDirs.length > 0) {
return {
reports: await Promise.all(
packageDirs.map((packageDir) =>
checkStaleness(packageDir, readPackageName(packageDir)),
),
),
}
}
}

const staleResult = await scanIntentsOrFail()
return {
reports: await Promise.all(
staleResult.packages.map((pkg) =>
checkStaleness(pkg.packageRoot, pkg.name),
),
),
}
}
Loading
Loading