@wyxos/zephyr 0.2.21 → 0.2.22

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/src/utils/git.mjs CHANGED
@@ -1,91 +1,91 @@
1
- import process from 'node:process'
2
- import { runCommand, runCommandCapture } from './command.mjs'
3
-
4
- export async function getCurrentBranch(rootDir = process.cwd(), { method = 'rev-parse' } = {}) {
5
- if (method === 'show-current') {
6
- const { stdout } = await runCommandCapture('git', ['branch', '--show-current'], { cwd: rootDir })
7
- return stdout.trim() || null
8
- }
9
-
10
- const { stdout } = await runCommandCapture('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: rootDir })
11
- return stdout.trim() || null
12
- }
13
-
14
- export async function getUpstreamRef(rootDir = process.cwd()) {
15
- try {
16
- const { stdout } = await runCommandCapture(
17
- 'git',
18
- ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'],
19
- { cwd: rootDir }
20
- )
21
-
22
- return stdout.trim() || null
23
- } catch {
24
- return null
25
- }
26
- }
27
-
28
- export async function ensureUpToDateWithUpstream({
29
- branch,
30
- upstreamRef,
31
- rootDir = process.cwd(),
32
- logStep = null,
33
- logWarning = null
34
- }) {
35
- if (!upstreamRef) {
36
- logWarning?.(`Branch ${branch} has no upstream configured; skipping ahead/behind checks.`)
37
- return
38
- }
39
-
40
- const [remoteName, ...branchParts] = upstreamRef.split('/')
41
- const remoteBranch = branchParts.join('/')
42
-
43
- if (remoteName && remoteBranch) {
44
- logStep?.(`Fetching latest updates from ${remoteName}/${remoteBranch}...`)
45
- try {
46
- await runCommand('git', ['fetch', remoteName, remoteBranch], { cwd: rootDir, stdio: 'ignore' })
47
- } catch (error) {
48
- throw new Error(`Failed to fetch ${upstreamRef}: ${error.message}`)
49
- }
50
- }
51
-
52
- const aheadResult = await runCommandCapture('git', ['rev-list', '--count', `${upstreamRef}..HEAD`], {
53
- cwd: rootDir
54
- })
55
- const behindResult = await runCommandCapture('git', ['rev-list', '--count', `HEAD..${upstreamRef}`], {
56
- cwd: rootDir
57
- })
58
-
59
- const ahead = Number.parseInt(aheadResult.stdout || '0', 10)
60
- const behind = Number.parseInt(behindResult.stdout || '0', 10)
61
-
62
- if (Number.isFinite(behind) && behind > 0) {
63
- if (remoteName && remoteBranch) {
64
- logStep?.(`Fast-forwarding ${branch} with ${upstreamRef}...`)
65
-
66
- try {
67
- await runCommand('git', ['pull', '--ff-only', remoteName, remoteBranch], { cwd: rootDir, stdio: 'ignore' })
68
- } catch (error) {
69
- throw new Error(
70
- `Unable to fast-forward ${branch} with ${upstreamRef}. Resolve conflicts manually, then rerun.\n${error.message}`
71
- )
72
- }
73
-
74
- await ensureUpToDateWithUpstream({ branch, upstreamRef, rootDir, logStep, logWarning })
75
- return
76
- }
77
-
78
- throw new Error(
79
- `Branch ${branch} is behind ${upstreamRef} by ${behind} commit${behind === 1 ? '' : 's'}. Pull or rebase first.`
80
- )
81
- }
82
-
83
- if (Number.isFinite(ahead) && ahead > 0) {
84
- logWarning?.(`Branch ${branch} is ahead of ${upstreamRef} by ${ahead} commit${ahead === 1 ? '' : 's'}.`)
85
- }
86
- }
87
-
88
- export async function pushQuiet(rootDir = process.cwd(), args = []) {
89
- await runCommandCapture('git', ['push', ...args], { cwd: rootDir })
90
- }
91
-
1
+ import process from 'node:process'
2
+ import { runCommand, runCommandCapture } from './command.mjs'
3
+
4
+ export async function getCurrentBranch(rootDir = process.cwd(), { method = 'rev-parse' } = {}) {
5
+ if (method === 'show-current') {
6
+ const { stdout } = await runCommandCapture('git', ['branch', '--show-current'], { cwd: rootDir })
7
+ return stdout.trim() || null
8
+ }
9
+
10
+ const { stdout } = await runCommandCapture('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: rootDir })
11
+ return stdout.trim() || null
12
+ }
13
+
14
+ export async function getUpstreamRef(rootDir = process.cwd()) {
15
+ try {
16
+ const { stdout } = await runCommandCapture(
17
+ 'git',
18
+ ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'],
19
+ { cwd: rootDir }
20
+ )
21
+
22
+ return stdout.trim() || null
23
+ } catch {
24
+ return null
25
+ }
26
+ }
27
+
28
+ export async function ensureUpToDateWithUpstream({
29
+ branch,
30
+ upstreamRef,
31
+ rootDir = process.cwd(),
32
+ logStep = null,
33
+ logWarning = null
34
+ }) {
35
+ if (!upstreamRef) {
36
+ logWarning?.(`Branch ${branch} has no upstream configured; skipping ahead/behind checks.`)
37
+ return
38
+ }
39
+
40
+ const [remoteName, ...branchParts] = upstreamRef.split('/')
41
+ const remoteBranch = branchParts.join('/')
42
+
43
+ if (remoteName && remoteBranch) {
44
+ logStep?.(`Fetching latest updates from ${remoteName}/${remoteBranch}...`)
45
+ try {
46
+ await runCommand('git', ['fetch', remoteName, remoteBranch], { cwd: rootDir, stdio: 'ignore' })
47
+ } catch (error) {
48
+ throw new Error(`Failed to fetch ${upstreamRef}: ${error.message}`)
49
+ }
50
+ }
51
+
52
+ const aheadResult = await runCommandCapture('git', ['rev-list', '--count', `${upstreamRef}..HEAD`], {
53
+ cwd: rootDir
54
+ })
55
+ const behindResult = await runCommandCapture('git', ['rev-list', '--count', `HEAD..${upstreamRef}`], {
56
+ cwd: rootDir
57
+ })
58
+
59
+ const ahead = Number.parseInt(aheadResult.stdout || '0', 10)
60
+ const behind = Number.parseInt(behindResult.stdout || '0', 10)
61
+
62
+ if (Number.isFinite(behind) && behind > 0) {
63
+ if (remoteName && remoteBranch) {
64
+ logStep?.(`Fast-forwarding ${branch} with ${upstreamRef}...`)
65
+
66
+ try {
67
+ await runCommand('git', ['pull', '--ff-only', remoteName, remoteBranch], { cwd: rootDir, stdio: 'ignore' })
68
+ } catch (error) {
69
+ throw new Error(
70
+ `Unable to fast-forward ${branch} with ${upstreamRef}. Resolve conflicts manually, then rerun.\n${error.message}`
71
+ )
72
+ }
73
+
74
+ await ensureUpToDateWithUpstream({ branch, upstreamRef, rootDir, logStep, logWarning })
75
+ return
76
+ }
77
+
78
+ throw new Error(
79
+ `Branch ${branch} is behind ${upstreamRef} by ${behind} commit${behind === 1 ? '' : 's'}. Pull or rebase first.`
80
+ )
81
+ }
82
+
83
+ if (Number.isFinite(ahead) && ahead > 0) {
84
+ logWarning?.(`Branch ${branch} is ahead of ${upstreamRef} by ${ahead} commit${ahead === 1 ? '' : 's'}.`)
85
+ }
86
+ }
87
+
88
+ export async function pushQuiet(rootDir = process.cwd(), args = []) {
89
+ await runCommandCapture('git', ['push', ...args], { cwd: rootDir })
90
+ }
91
+
package/src/utils/id.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import crypto from 'node:crypto'
2
-
3
- export function generateId() {
4
- return crypto.randomBytes(8).toString('hex')
5
- }
6
-
1
+ import crypto from 'node:crypto'
2
+
3
+ export function generateId() {
4
+ return crypto.randomBytes(8).toString('hex')
5
+ }
6
+
@@ -1,76 +1,76 @@
1
- import fs from 'node:fs/promises'
2
- import path from 'node:path'
3
- import { ensureDirectory, getProjectConfigDir } from './paths.mjs'
4
-
5
- let logFilePath = null
6
-
7
- export async function getLogFilePath(rootDir) {
8
- if (logFilePath) {
9
- return logFilePath
10
- }
11
-
12
- const configDir = getProjectConfigDir(rootDir)
13
- await ensureDirectory(configDir)
14
-
15
- const now = new Date()
16
- const dateStr = now.toISOString().replace(/:/g, '-').replace(/\..+/, '')
17
- logFilePath = path.join(configDir, `${dateStr}.log`)
18
-
19
- return logFilePath
20
- }
21
-
22
- export async function writeToLogFile(rootDir, message) {
23
- const logPath = await getLogFilePath(rootDir)
24
- const timestamp = new Date().toISOString()
25
- await fs.appendFile(logPath, `${timestamp} - ${message}\n`)
26
- }
27
-
28
- export async function closeLogFile() {
29
- logFilePath = null
30
- }
31
-
32
- export async function cleanupOldLogs(rootDir) {
33
- const configDir = getProjectConfigDir(rootDir)
34
-
35
- try {
36
- const files = await fs.readdir(configDir)
37
- const logFiles = files
38
- .filter((file) => file.endsWith('.log'))
39
- .map((file) => ({
40
- name: file,
41
- path: path.join(configDir, file)
42
- }))
43
-
44
- if (logFiles.length <= 3) {
45
- return
46
- }
47
-
48
- const filesWithStats = await Promise.all(
49
- logFiles.map(async (file) => {
50
- const stats = await fs.stat(file.path)
51
- return {
52
- ...file,
53
- mtime: stats.mtime
54
- }
55
- })
56
- )
57
-
58
- filesWithStats.sort((a, b) => b.mtime - a.mtime)
59
-
60
- const filesToDelete = filesWithStats.slice(3)
61
-
62
- for (const file of filesToDelete) {
63
- try {
64
- await fs.unlink(file.path)
65
- } catch (_error) {
66
- // Ignore errors when deleting old logs
67
- }
68
- }
69
- } catch (error) {
70
- // Ignore errors during log cleanup
71
- if (error.code !== 'ENOENT') {
72
- // Only log if it's not a "directory doesn't exist" error
73
- }
74
- }
75
- }
76
-
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { ensureDirectory, getProjectConfigDir } from './paths.mjs'
4
+
5
+ let logFilePath = null
6
+
7
+ export async function getLogFilePath(rootDir) {
8
+ if (logFilePath) {
9
+ return logFilePath
10
+ }
11
+
12
+ const configDir = getProjectConfigDir(rootDir)
13
+ await ensureDirectory(configDir)
14
+
15
+ const now = new Date()
16
+ const dateStr = now.toISOString().replace(/:/g, '-').replace(/\..+/, '')
17
+ logFilePath = path.join(configDir, `${dateStr}.log`)
18
+
19
+ return logFilePath
20
+ }
21
+
22
+ export async function writeToLogFile(rootDir, message) {
23
+ const logPath = await getLogFilePath(rootDir)
24
+ const timestamp = new Date().toISOString()
25
+ await fs.appendFile(logPath, `${timestamp} - ${message}\n`)
26
+ }
27
+
28
+ export async function closeLogFile() {
29
+ logFilePath = null
30
+ }
31
+
32
+ export async function cleanupOldLogs(rootDir) {
33
+ const configDir = getProjectConfigDir(rootDir)
34
+
35
+ try {
36
+ const files = await fs.readdir(configDir)
37
+ const logFiles = files
38
+ .filter((file) => file.endsWith('.log'))
39
+ .map((file) => ({
40
+ name: file,
41
+ path: path.join(configDir, file)
42
+ }))
43
+
44
+ if (logFiles.length <= 3) {
45
+ return
46
+ }
47
+
48
+ const filesWithStats = await Promise.all(
49
+ logFiles.map(async (file) => {
50
+ const stats = await fs.stat(file.path)
51
+ return {
52
+ ...file,
53
+ mtime: stats.mtime
54
+ }
55
+ })
56
+ )
57
+
58
+ filesWithStats.sort((a, b) => b.mtime - a.mtime)
59
+
60
+ const filesToDelete = filesWithStats.slice(3)
61
+
62
+ for (const file of filesToDelete) {
63
+ try {
64
+ await fs.unlink(file.path)
65
+ } catch (_error) {
66
+ // Ignore errors when deleting old logs
67
+ }
68
+ }
69
+ } catch (error) {
70
+ // Ignore errors during log cleanup
71
+ if (error.code !== 'ENOENT') {
72
+ // Only log if it's not a "directory doesn't exist" error
73
+ }
74
+ }
75
+ }
76
+
@@ -1,29 +1,29 @@
1
- import process from 'node:process'
2
-
3
- export function writeStdoutLine(message = '') {
4
- const text = message == null ? '' : String(message)
5
- process.stdout.write(`${text}\n`)
6
- }
7
-
8
- export function writeStderrLine(message = '') {
9
- const text = message == null ? '' : String(message)
10
- process.stderr.write(`${text}\n`)
11
- }
12
-
13
- export function writeStderr(message = '') {
14
- const text = message == null ? '' : String(message)
15
- process.stderr.write(text)
16
- if (text && !text.endsWith('\n')) {
17
- process.stderr.write('\n')
18
- }
19
- }
20
-
21
- export function createChalkLogger(chalk) {
22
- return {
23
- logProcessing: (message = '') => writeStdoutLine(chalk.yellow(message)),
24
- logSuccess: (message = '') => writeStdoutLine(chalk.green(message)),
25
- logWarning: (message = '') => writeStderrLine(chalk.yellow(message)),
26
- logError: (message = '') => writeStderrLine(chalk.red(message))
27
- }
28
- }
29
-
1
+ import process from 'node:process'
2
+
3
+ export function writeStdoutLine(message = '') {
4
+ const text = message == null ? '' : String(message)
5
+ process.stdout.write(`${text}\n`)
6
+ }
7
+
8
+ export function writeStderrLine(message = '') {
9
+ const text = message == null ? '' : String(message)
10
+ process.stderr.write(`${text}\n`)
11
+ }
12
+
13
+ export function writeStderr(message = '') {
14
+ const text = message == null ? '' : String(message)
15
+ process.stderr.write(text)
16
+ if (text && !text.endsWith('\n')) {
17
+ process.stderr.write('\n')
18
+ }
19
+ }
20
+
21
+ export function createChalkLogger(chalk) {
22
+ return {
23
+ logProcessing: (message = '') => writeStdoutLine(chalk.yellow(message)),
24
+ logSuccess: (message = '') => writeStdoutLine(chalk.green(message)),
25
+ logWarning: (message = '') => writeStderrLine(chalk.yellow(message)),
26
+ logError: (message = '') => writeStderrLine(chalk.red(message))
27
+ }
28
+ }
29
+
@@ -1,28 +1,28 @@
1
- import fs from 'node:fs/promises'
2
- import path from 'node:path'
3
-
4
- export const PROJECT_CONFIG_DIR = '.zephyr'
5
- export const PROJECT_CONFIG_FILE = 'config.json'
6
- export const PROJECT_LOCK_FILE = 'deploy.lock'
7
- export const PENDING_TASKS_FILE = 'pending-tasks.json'
8
-
9
- export async function ensureDirectory(dirPath) {
10
- await fs.mkdir(dirPath, { recursive: true })
11
- }
12
-
13
- export function getProjectConfigDir(rootDir) {
14
- return path.join(rootDir, PROJECT_CONFIG_DIR)
15
- }
16
-
17
- export function getProjectConfigPath(rootDir) {
18
- return path.join(rootDir, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILE)
19
- }
20
-
21
- export function getPendingTasksPath(rootDir) {
22
- return path.join(getProjectConfigDir(rootDir), PENDING_TASKS_FILE)
23
- }
24
-
25
- export function getLockFilePath(rootDir) {
26
- return path.join(getProjectConfigDir(rootDir), PROJECT_LOCK_FILE)
27
- }
28
-
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ export const PROJECT_CONFIG_DIR = '.zephyr'
5
+ export const PROJECT_CONFIG_FILE = 'config.json'
6
+ export const PROJECT_LOCK_FILE = 'deploy.lock'
7
+ export const PENDING_TASKS_FILE = 'pending-tasks.json'
8
+
9
+ export async function ensureDirectory(dirPath) {
10
+ await fs.mkdir(dirPath, { recursive: true })
11
+ }
12
+
13
+ export function getProjectConfigDir(rootDir) {
14
+ return path.join(rootDir, PROJECT_CONFIG_DIR)
15
+ }
16
+
17
+ export function getProjectConfigPath(rootDir) {
18
+ return path.join(rootDir, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILE)
19
+ }
20
+
21
+ export function getPendingTasksPath(rootDir) {
22
+ return path.join(getProjectConfigDir(rootDir), PENDING_TASKS_FILE)
23
+ }
24
+
25
+ export function getLockFilePath(rootDir) {
26
+ return path.join(getProjectConfigDir(rootDir), PROJECT_LOCK_FILE)
27
+ }
28
+
@@ -0,0 +1,137 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import semver from 'semver'
4
+
5
+ /**
6
+ * Extracts the minimum PHP version requirement from a composer.json object
7
+ * @param {object} composer - Parsed composer.json object
8
+ * @returns {string|null} - PHP version requirement (e.g., "8.4.0") or null
9
+ */
10
+ export function parsePhpVersionRequirement(composer) {
11
+ const phpRequirement = composer?.require?.php || composer?.['require-dev']?.php
12
+ if (!phpRequirement) {
13
+ return null
14
+ }
15
+
16
+ // Parse version constraint (e.g., "^8.4", ">=8.4.0", "8.4.*", "~8.4.0")
17
+ // Extract the minimum version needed
18
+ const versionMatch = phpRequirement.match(/(\d+)\.(\d+)(?:\.(\d+))?/)
19
+ if (!versionMatch) {
20
+ return null
21
+ }
22
+
23
+ const major = versionMatch[1]
24
+ const minor = versionMatch[2]
25
+ const patch = versionMatch[3] || '0'
26
+
27
+ const versionStr = `${major}.${minor}.${patch}`
28
+
29
+ // Normalize to semver format
30
+ if (semver.valid(versionStr)) {
31
+ return versionStr
32
+ }
33
+
34
+ // Try to coerce to valid semver
35
+ const coerced = semver.coerce(versionStr)
36
+ if (coerced) {
37
+ return coerced.version
38
+ }
39
+
40
+ return null
41
+ }
42
+
43
+ /**
44
+ * Extracts the minimum PHP version requirement from composer.json file
45
+ * @param {string} rootDir - Project root directory
46
+ * @returns {Promise<string|null>} - PHP version requirement (e.g., "8.4.0") or null
47
+ */
48
+ export async function getPhpVersionRequirement(rootDir) {
49
+ try {
50
+ const composerPath = path.join(rootDir, 'composer.json')
51
+ const raw = await fs.readFile(composerPath, 'utf8')
52
+ const composer = JSON.parse(raw)
53
+ return parsePhpVersionRequirement(composer)
54
+ } catch {
55
+ return null
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Finds the appropriate PHP binary command for a given version
61
+ * Tries common patterns: php8.4, php8.3, etc.
62
+ * @param {object} ssh - SSH client instance
63
+ * @param {string} remoteCwd - Remote working directory
64
+ * @param {string} requiredVersion - Required PHP version (e.g., "8.4.0")
65
+ * @returns {Promise<string>} - PHP command prefix (e.g., "php8.4" or "php")
66
+ */
67
+ export async function findPhpBinary(ssh, remoteCwd, requiredVersion) {
68
+ if (!requiredVersion) {
69
+ return 'php'
70
+ }
71
+
72
+ // Extract major.minor version (e.g., "8.4" from "8.4.0")
73
+ const majorMinor = semver.major(requiredVersion) + '.' + semver.minor(requiredVersion)
74
+ const versionedPhp = `php${majorMinor.replace('.', '')}` // e.g., "php84"
75
+
76
+ // Try versioned PHP binary first (e.g., php8.4, php84)
77
+ const candidates = [
78
+ `php${majorMinor}`, // php8.4
79
+ versionedPhp, // php84
80
+ 'php' // fallback
81
+ ]
82
+
83
+ for (const candidate of candidates) {
84
+ try {
85
+ const result = await ssh.execCommand(`command -v ${candidate}`, { cwd: remoteCwd })
86
+ if (result.code === 0 && result.stdout.trim()) {
87
+ // Verify it's actually the right version
88
+ const versionCheck = await ssh.execCommand(`${candidate} -r "echo PHP_VERSION;"`, { cwd: remoteCwd })
89
+ if (versionCheck.code === 0) {
90
+ const actualVersion = versionCheck.stdout.trim()
91
+ // Normalize version and check if it satisfies the requirement
92
+ const normalizedVersion = semver.coerce(actualVersion)
93
+ if (normalizedVersion && semver.gte(normalizedVersion, semver.coerce(requiredVersion))) {
94
+ return candidate
95
+ }
96
+ }
97
+ }
98
+ } catch {
99
+ // Continue to next candidate
100
+ }
101
+ }
102
+
103
+ // Fallback: try to use default php and check version
104
+ try {
105
+ const versionCheck = await ssh.execCommand('php -r "echo PHP_VERSION;"', { cwd: remoteCwd })
106
+ if (versionCheck.code === 0) {
107
+ const actualVersion = versionCheck.stdout.trim()
108
+ const normalizedVersion = semver.coerce(actualVersion)
109
+ if (normalizedVersion && semver.gte(normalizedVersion, semver.coerce(requiredVersion))) {
110
+ return 'php'
111
+ }
112
+ }
113
+ } catch {
114
+ // Ignore
115
+ }
116
+
117
+ // If we can't find a suitable version, return the versioned command anyway
118
+ // The error will be clearer when the command fails
119
+ return `php${majorMinor}`
120
+ }
121
+
122
+ /**
123
+ * Gets the PHP command prefix for use in commands
124
+ * @param {object} ssh - SSH client instance
125
+ * @param {string} remoteCwd - Remote working directory
126
+ * @param {string} rootDir - Local project root directory
127
+ * @returns {Promise<string>} - PHP command prefix (e.g., "php8.4" or "php")
128
+ */
129
+ export async function getPhpCommandPrefix(ssh, remoteCwd, rootDir) {
130
+ const requiredVersion = await getPhpVersionRequirement(rootDir)
131
+
132
+ if (!requiredVersion) {
133
+ return 'php'
134
+ }
135
+
136
+ return await findPhpBinary(ssh, remoteCwd, requiredVersion)
137
+ }
@@ -1,23 +1,23 @@
1
- export function resolveRemotePath(projectPath, remoteHome) {
2
- if (!projectPath) {
3
- return projectPath
4
- }
5
-
6
- const sanitizedHome = remoteHome.replace(/\/+$/, '')
7
-
8
- if (projectPath === '~') {
9
- return sanitizedHome
10
- }
11
-
12
- if (projectPath.startsWith('~/')) {
13
- const remainder = projectPath.slice(2)
14
- return remainder ? `${sanitizedHome}/${remainder}` : sanitizedHome
15
- }
16
-
17
- if (projectPath.startsWith('/')) {
18
- return projectPath
19
- }
20
-
21
- return `${sanitizedHome}/${projectPath}`
22
- }
23
-
1
+ export function resolveRemotePath(projectPath, remoteHome) {
2
+ if (!projectPath) {
3
+ return projectPath
4
+ }
5
+
6
+ const sanitizedHome = remoteHome.replace(/\/+$/, '')
7
+
8
+ if (projectPath === '~') {
9
+ return sanitizedHome
10
+ }
11
+
12
+ if (projectPath.startsWith('~/')) {
13
+ const remainder = projectPath.slice(2)
14
+ return remainder ? `${sanitizedHome}/${remainder}` : sanitizedHome
15
+ }
16
+
17
+ if (projectPath.startsWith('/')) {
18
+ return projectPath
19
+ }
20
+
21
+ return `${sanitizedHome}/${projectPath}`
22
+ }
23
+