@mnstry/atelier 0.2.0-alpha.5 → 0.2.0-alpha.6

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.
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { spawnSync } from 'node:child_process'
4
+ import { sanitizedGitEnvironment } from '../runtime/git-adapter.mjs'
4
5
 
5
6
  export const PROJECT_CONFIG_ARG_PREFIX = '--project-config='
6
7
  export const PROJECT_CONFIG_ENV = 'MNSTRY_ATELIER_PROJECT_CONFIG'
@@ -195,25 +196,25 @@ function overlayRepoPath(overlay, repoName) {
195
196
  return firstString(direct, repo.path, repo.localPath)
196
197
  }
197
198
 
198
- function resolveRepoPath({ repo, repoName, configDir, workspaceRoot, overlay, cliRepoPaths }) {
199
+ function resolveRepoPath({ repo, repoName, configDir, workspaceRoot, overlay, cliRepoPaths, gitExecutable, env }) {
199
200
  if (repoName && cliRepoPaths.has(repoName)) return cliRepoPaths.get(repoName)
200
201
  const overlayPath = overlayRepoPath(overlay, repoName)
201
202
  const fromOverlay = resolvePathValue(overlayPath, configDir)
202
203
  if (fromOverlay) return fromOverlay
203
204
  const fromConfig = resolvePathValue(repo.path, configDir) || resolvePathValue(repo.path, workspaceRoot)
204
205
  if (fromConfig) return fromConfig
205
- return discoverSiblingRepoPath({ repo, repoName, configDir, workspaceRoot })
206
+ return discoverSiblingRepoPath({ repo, repoName, configDir, workspaceRoot, gitExecutable, env })
206
207
  }
207
208
 
208
- function repoPathSource({ repo, repoName, overlay, cliRepoPaths, workspaceRoot, configDir }) {
209
+ function repoPathSource({ repo, repoName, overlay, cliRepoPaths, workspaceRoot, configDir, gitExecutable, env }) {
209
210
  if (repoName && cliRepoPaths.has(repoName)) return 'cli'
210
211
  if (overlayRepoPath(overlay, repoName)) return 'local-overlay'
211
212
  if (firstString(repo.path)) return 'tracked-config'
212
- if (discoverSiblingRepoPath({ repo, repoName, configDir, workspaceRoot })) return 'sibling-discovery'
213
+ if (discoverSiblingRepoPath({ repo, repoName, configDir, workspaceRoot, gitExecutable, env })) return 'sibling-discovery'
213
214
  return null
214
215
  }
215
216
 
216
- function discoverSiblingRepoPath({ repo, repoName, configDir, workspaceRoot }) {
217
+ function discoverSiblingRepoPath({ repo, repoName, configDir, workspaceRoot, gitExecutable = 'git', env = process.env }) {
217
218
  if (!repoName) return null
218
219
  const candidates = [
219
220
  path.join(workspaceRoot, repoName),
@@ -222,14 +223,14 @@ function discoverSiblingRepoPath({ repo, repoName, configDir, workspaceRoot }) {
222
223
  ]
223
224
  for (const candidate of candidates) {
224
225
  if (!fs.existsSync(candidate)) continue
225
- if (repo.remote && !gitRemoteMatches(candidate, repo.remote)) continue
226
+ if (repo.remote && !gitRemoteMatches(candidate, repo.remote, { gitExecutable, env })) continue
226
227
  return path.resolve(candidate)
227
228
  }
228
229
  return null
229
230
  }
230
231
 
231
- function gitRemoteMatches(repoPath, expected) {
232
- const result = spawnSync('git', ['-C', repoPath, 'remote', 'get-url', 'origin'], { encoding: 'utf8' })
232
+ function gitRemoteMatches(repoPath, expected, { gitExecutable = 'git', env = process.env } = {}) {
233
+ const result = spawnSync(gitExecutable, ['-C', repoPath, 'remote', 'get-url', 'origin'], { encoding: 'utf8', env: sanitizedGitEnvironment(env) })
233
234
  if (result.status !== 0) return false
234
235
  const actual = normalizeRemote(result.stdout.trim())
235
236
  return actual === normalizeRemote(expected)
@@ -243,9 +244,9 @@ function normalizeRemote(value) {
243
244
  .toLowerCase()
244
245
  }
245
246
 
246
- export function gitRemoteUrl(repoPath) {
247
+ export function gitRemoteUrl(repoPath, { gitExecutable = 'git', env = process.env } = {}) {
247
248
  if (!repoPath || !fs.existsSync(repoPath)) return null
248
- const result = spawnSync('git', ['-C', repoPath, 'remote', 'get-url', 'origin'], { encoding: 'utf8' })
249
+ const result = spawnSync(gitExecutable, ['-C', repoPath, 'remote', 'get-url', 'origin'], { encoding: 'utf8', env: sanitizedGitEnvironment(env) })
249
250
  return result.status === 0 ? firstString(result.stdout) : null
250
251
  }
251
252
 
@@ -268,6 +269,7 @@ export function resolveProjectConfig({
268
269
  env = process.env,
269
270
  cwd = process.cwd(),
270
271
  configArgPrefix = PROJECT_CONFIG_ARG_PREFIX,
272
+ gitExecutable = 'git',
271
273
  configEnv = PROJECT_CONFIG_ENV,
272
274
  defaults = {},
273
275
  } = {}) {
@@ -373,7 +375,7 @@ export function resolveProjectConfig({
373
375
  localOverlay,
374
376
  repos: (Array.isArray(config.repos) ? config.repos : []).map((repo) => {
375
377
  const repoName = firstString(repo.name) || (repo.path ? path.basename(repo.path) : null)
376
- const repoPath = resolveRepoPath({ repo, repoName, configDir, workspaceRoot, overlay: localOverlay.overlay, cliRepoPaths })
378
+ const repoPath = resolveRepoPath({ repo, repoName, configDir, workspaceRoot, overlay: localOverlay.overlay, cliRepoPaths, gitExecutable, env })
377
379
  const external = isExternalRepo(repo)
378
380
  return {
379
381
  ...repo,
@@ -381,16 +383,16 @@ export function resolveProjectConfig({
381
383
  path: repoPath,
382
384
  external,
383
385
  readBoundary: external ? null : firstString(repo.readBoundary) || 'team',
384
- pathSource: repoPath ? repoPathSource({ repo, repoName, overlay: localOverlay.overlay, cliRepoPaths, workspaceRoot, configDir }) : null,
386
+ pathSource: repoPath ? repoPathSource({ repo, repoName, overlay: localOverlay.overlay, cliRepoPaths, workspaceRoot, configDir, gitExecutable, env }) : null,
385
387
  }
386
388
  }),
387
389
  }
388
- resolved.localState = ensureLocalState(resolved, { write: true })
390
+ resolved.localState = ensureLocalState(resolved, { write: true, gitExecutable, env })
389
391
  return resolved
390
392
  }
391
393
 
392
- export function commandProject({ argv = process.argv.slice(2), env = process.env, cwd = process.cwd() } = {}) {
393
- const project = resolveProjectConfig({ argv, env, cwd })
394
+ export function commandProject({ argv = process.argv.slice(2), env = process.env, cwd = process.cwd(), gitExecutable = 'git' } = {}) {
395
+ const project = resolveProjectConfig({ argv, env, cwd, gitExecutable })
394
396
  // Fail closed at CLI entry, but only when a real config file was loaded; the
395
397
  // defaults/no-file path resolves with an empty config that would spuriously
396
398
  // fail document validation.
@@ -419,35 +421,35 @@ export function localStateRoot(projectOrDir) {
419
421
  return path.join(configDir || process.cwd(), LOCAL_STATE_DIR)
420
422
  }
421
423
 
422
- function gitRootFor(dir) {
423
- const result = spawnSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' })
424
+ function gitRootFor(dir, { gitExecutable = 'git', env = process.env } = {}) {
425
+ const result = spawnSync(gitExecutable, ['-C', dir, 'rev-parse', '--show-toplevel'], { encoding: 'utf8', env: sanitizedGitEnvironment(env) })
424
426
  return result.status === 0 ? result.stdout.trim() : null
425
427
  }
426
428
 
427
- function gitPrefixFor(dir) {
428
- const result = spawnSync('git', ['-C', dir, 'rev-parse', '--show-prefix'], { encoding: 'utf8' })
429
+ function gitPrefixFor(dir, { gitExecutable = 'git', env = process.env } = {}) {
430
+ const result = spawnSync(gitExecutable, ['-C', dir, 'rev-parse', '--show-prefix'], { encoding: 'utf8', env: sanitizedGitEnvironment(env) })
429
431
  return result.status === 0 ? result.stdout.trim() : null
430
432
  }
431
433
 
432
- function isIgnoredByGit(gitRoot, rel) {
433
- const result = spawnSync('git', ['-C', gitRoot, 'check-ignore', '-q', rel], { encoding: 'utf8' })
434
+ function isIgnoredByGit(gitRoot, rel, { gitExecutable = 'git', env = process.env } = {}) {
435
+ const result = spawnSync(gitExecutable, ['-C', gitRoot, 'check-ignore', '-q', rel], { encoding: 'utf8', env: sanitizedGitEnvironment(env) })
434
436
  return result.status === 0
435
437
  }
436
438
 
437
- export function ensureLocalState(project, { write = false } = {}) {
439
+ export function ensureLocalState(project, { write = false, gitExecutable = 'git', env = process.env } = {}) {
438
440
  const root = localStateRoot(project)
439
- const gitRoot = gitRootFor(project.configDir)
441
+ const gitRoot = gitRootFor(project.configDir, { gitExecutable, env })
440
442
  // Git owns the repository-relative spelling. Deriving it by comparing
441
443
  // filesystem paths breaks when Windows exposes the cwd through an 8.3 short
442
444
  // name (RUNNER~1) but Git reports the same root through its long name.
443
- const gitPrefix = gitRoot ? gitPrefixFor(project.configDir) : null
445
+ const gitPrefix = gitRoot ? gitPrefixFor(project.configDir, { gitExecutable, env }) : null
444
446
  const rel = gitRoot && gitPrefix !== null ? `${gitPrefix}${LOCAL_STATE_DIR}` : LOCAL_STATE_DIR
445
447
  // A directory-only ignore rule such as `.atelier-local/` is not evaluated
446
448
  // consistently by Git for an absent directory on every host. Probe a
447
449
  // hypothetical child as well: it proves the directory rule before Atelier
448
450
  // creates any local state, including on Git for Windows.
449
451
  const ignored = gitRoot && gitPrefix !== null
450
- ? isIgnoredByGit(gitRoot, `${rel}/.atelier-ignore-probe`) || isIgnoredByGit(gitRoot, `${rel}/`) || isIgnoredByGit(gitRoot, rel)
452
+ ? isIgnoredByGit(gitRoot, `${rel}/.atelier-ignore-probe`, { gitExecutable, env }) || isIgnoredByGit(gitRoot, `${rel}/`, { gitExecutable, env }) || isIgnoredByGit(gitRoot, rel, { gitExecutable, env })
451
453
  : gitRoot === null
452
454
  const report = {
453
455
  root,
@@ -0,0 +1,189 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+
5
+ const DEFAULT_TIMEOUT_MS = 30_000
6
+ const DEFAULT_MAX_BUFFER = 16 * 1024 * 1024
7
+
8
+ function executableNames(platform = process.platform, env = process.env) {
9
+ if (platform !== 'win32') return ['git']
10
+ const extensions = String(env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
11
+ .split(';')
12
+ .map((value) => value.trim().toLowerCase())
13
+ .filter(Boolean)
14
+ return ['git.exe', ...extensions.map((extension) => `git${extension}`).filter((name) => name !== 'git.exe')]
15
+ }
16
+
17
+ function isExecutable(file, platform = process.platform) {
18
+ try {
19
+ fs.accessSync(file, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK)
20
+ return fs.statSync(file).isFile()
21
+ } catch {
22
+ return false
23
+ }
24
+ }
25
+
26
+ function canonicalExecutable(file) {
27
+ try {
28
+ return fs.realpathSync.native(file)
29
+ } catch {
30
+ return fs.realpathSync(file)
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Resolve one exact Git executable without invoking a shell. The selected path
36
+ * is carried through every later operation so PATH changes cannot swap engines
37
+ * underneath a running supervisor.
38
+ */
39
+ export function resolveGitExecutable({ env = process.env, platform = process.platform } = {}) {
40
+ const configured = String(env.ATELIER_GIT_PATH || '').trim()
41
+ if (configured) {
42
+ if (!path.isAbsolute(configured)) throw new Error('ATELIER_GIT_PATH must be an absolute path')
43
+ if (!isExecutable(configured, platform)) throw new Error(`configured Git executable is unavailable: ${configured}`)
44
+ return canonicalExecutable(configured)
45
+ }
46
+
47
+ const searchPath = String(env.PATH || '')
48
+ const delimiter = platform === 'win32' ? ';' : path.delimiter
49
+ const names = executableNames(platform, env)
50
+ for (const directory of searchPath.split(delimiter).filter(Boolean)) {
51
+ const cleanDir = directory.replace(/^"|"$/g, '')
52
+ for (const name of names) {
53
+ const candidate = path.join(cleanDir, name)
54
+ if (isExecutable(candidate, platform)) return canonicalExecutable(candidate)
55
+ }
56
+ }
57
+ throw new Error('compatible system Git was not found on PATH')
58
+ }
59
+
60
+ export function sanitizedGitEnvironment(env = process.env, allowPrompt = false) {
61
+ const next = { ...env }
62
+ for (const key of Object.keys(next)) {
63
+ if (key.toUpperCase().startsWith('GIT_')) delete next[key]
64
+ }
65
+ if (!allowPrompt) next.GIT_TERMINAL_PROMPT = '0'
66
+ next.GIT_OPTIONAL_LOCKS = next.GIT_OPTIONAL_LOCKS || '1'
67
+ next.GIT_NO_REPLACE_OBJECTS = '1'
68
+ return next
69
+ }
70
+
71
+ export function redactGitDiagnostic(value) {
72
+ return String(value || '')
73
+ .replace(/https?:\/\/[^\s]+/gi, (url) => sanitizeRemoteUrl(url))
74
+ .replace(/\b((?:password|passwd|token|secret|authorization|proxy-authorization|extraheader)\s*[=:]\s*)[^\s]+/gi, '$1[redacted]')
75
+ .replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, '$1 [redacted]')
76
+ }
77
+
78
+ export class GitCommandError extends Error {
79
+ constructor(message, result) {
80
+ super(message)
81
+ this.name = 'GitCommandError'
82
+ this.result = result
83
+ }
84
+ }
85
+
86
+ export function runGit(gitExecutable, repoRoot, args, {
87
+ allowFailure = false,
88
+ allowPrompt = false,
89
+ env = process.env,
90
+ input = undefined,
91
+ timeout = DEFAULT_TIMEOUT_MS,
92
+ } = {}) {
93
+ if (!path.isAbsolute(gitExecutable)) throw new Error('Git executable must be an absolute path')
94
+ if (!Array.isArray(args) || args.some((value) => typeof value !== 'string')) {
95
+ throw new Error('Git arguments must be a string array')
96
+ }
97
+ const argv = repoRoot ? ['-C', repoRoot, ...args] : args
98
+ const child = spawnSync(gitExecutable, argv, {
99
+ encoding: 'utf8',
100
+ env: sanitizedGitEnvironment(env, allowPrompt),
101
+ input,
102
+ maxBuffer: DEFAULT_MAX_BUFFER,
103
+ shell: false,
104
+ timeout,
105
+ windowsHide: true,
106
+ })
107
+ const result = {
108
+ ok: !child.error && child.status === 0,
109
+ status: child.status,
110
+ signal: child.signal ?? null,
111
+ stdout: child.stdout || '',
112
+ stderr: redactGitDiagnostic(child.stderr || ''),
113
+ error: child.error?.message ? redactGitDiagnostic(child.error.message) : null,
114
+ executable: gitExecutable,
115
+ args: argv,
116
+ }
117
+ if (!result.ok && !allowFailure) {
118
+ const detail = result.error || result.stderr.trim() || `exit ${String(result.status)}`
119
+ throw new GitCommandError(`Git ${args[0] || 'command'} failed: ${detail}`, result)
120
+ }
121
+ return result
122
+ }
123
+
124
+ export function gitText(gitExecutable, repoRoot, args, options = {}) {
125
+ return runGit(gitExecutable, repoRoot, args, options).stdout.trim()
126
+ }
127
+
128
+ export function parseGitVersion(text) {
129
+ const match = String(text || '').match(/git version (\d+)\.(\d+)\.(\d+)/i)
130
+ if (!match) return null
131
+ return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), text: match[0] }
132
+ }
133
+
134
+ export function isSupportedGitVersion(version) {
135
+ return Boolean(version && (version.major > 2 || (version.major === 2 && version.minor >= 40)))
136
+ }
137
+
138
+ export function inspectGitEngine(gitExecutable, { env = process.env } = {}) {
139
+ const result = runGit(gitExecutable, null, ['--version'], { env })
140
+ const version = parseGitVersion(result.stdout)
141
+ if (!version) throw new Error(`unrecognized Git version output from ${gitExecutable}`)
142
+ return {
143
+ executable: gitExecutable,
144
+ version: `${version.major}.${version.minor}.${version.patch}`,
145
+ supported: isSupportedGitVersion(version),
146
+ minimum: '2.40.0',
147
+ }
148
+ }
149
+
150
+ export function classifyRemoteAuthentication(url) {
151
+ const value = String(url || '').trim()
152
+ if (!value) return 'none'
153
+ if (/^(?:ssh:\/\/|[^/@\s]+@[^:/\s]+:)/i.test(value)) return 'ssh'
154
+ if (/^https?:\/\//i.test(value)) return 'https'
155
+ if (/^(?:file:\/\/|\.{0,2}[\\/]|[a-z]:[\\/]|[\\/])/i.test(value)) return 'local'
156
+ return 'unknown'
157
+ }
158
+
159
+ /**
160
+ * Remote URLs are evidence, not authentication material. Git may carry HTTP
161
+ * credentials in user-info or query fragments, so local observations retain
162
+ * only the provider/repository address needed for diagnosis.
163
+ */
164
+ export function sanitizeRemoteUrl(url) {
165
+ const value = String(url || '').trim()
166
+ const scpLike = value.match(/^[^/@\s]+@([^:/\s]+:.+)$/)
167
+ if (scpLike) return scpLike[1]
168
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return value
169
+ try {
170
+ const parsed = new URL(value)
171
+ parsed.username = ''
172
+ Reflect.set(parsed, 'password', '')
173
+ parsed.search = ''
174
+ parsed.hash = ''
175
+ return parsed.toString()
176
+ } catch {
177
+ return value.replace(/^((?:https?|ssh):\/\/)(?:[^/@:]+:)?[^/@]+@/i, '$1')
178
+ }
179
+ }
180
+
181
+ export function parseNullConfig(text) {
182
+ const entries = []
183
+ for (const record of String(text || '').split('\0').filter(Boolean)) {
184
+ const newline = record.indexOf('\n')
185
+ if (newline === -1) entries.push({ key: record, value: '' })
186
+ else entries.push({ key: record.slice(0, newline), value: record.slice(newline + 1) })
187
+ }
188
+ return entries
189
+ }