@jkwd/inbase 0.1.9 → 0.1.11

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joris Kuijper
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  </picture>
6
6
  </p>
7
7
 
8
- A first-person 3D map of a JavaScript or TypeScript codebase. Files become blocks, folders become walkable areas, and imports become lines in the air.
8
+ A first-person 3D map of a codebase. Files become blocks, folders become walkable areas, and imports become lines in the air.
9
9
 
10
10
  <p align="center">
11
11
  <img src="docs/inbase-1.png" alt="First-person walk view of the codebase map" width="47%" />
@@ -47,13 +47,21 @@ inbase run --target /path/to/your/project
47
47
 
48
48
  <img src="docs/inbase-llm.png" alt="LLM working through a plan in the heads-up display overlay" align="left" width="280" hspace="16" />
49
49
 
50
- Inbase does not call a model itself. The visual coding loop currently supports **Cursor**. The installed skill makes the agent work through the map: it reports a plan, waits on the **HUD** (heads-up display — the overlay panel on the 3D map), and publishes each step as a patch you can walk before it is applied.
50
+ Inbase does not call a model itself. The visual coding loop currently supports **Cursor**. The installed skill makes the agent work through the map: it reports a plan, waits on the **HUD** (heads-up display — the overlay panel on the 3D map), edits live files for each invoked step, and records that step as a patch. Those stored patches are the session record and are applied on every step update.
51
51
 
52
- When a chat starts, that overlay asks whether to set up a **blueprint**. **Create blueprint** lets you place files (`Space`) and folders (`B`), then **Send blueprint** that layout is the source of truth for the chat. **Let LLM continue** skips placement and lets the agent choose files from the request.
52
+ Sessions start in the map: click **Setup LLM session**, type an **initial instruction**, optionally place a **blueprint** (`Space` for files, `B` for folders), then open a Cursor chat and run **`/inbase`**. That command connects the chat and starts the work. The layout is the source of truth for the chat. You can still place files and islands on later steps; they are stored on that chat’s blueprint. When the session finishes, placement stops and the session is discarded. Restarting the visualizer also starts with no LLM session; leftover session files are not restored.
53
53
 
54
- Turn on **Make LLM look where I look** if the agent should prefer the island you are standing on and the blocks you are facing. With **Step by step** on, click **Run step** for each patch. With it off, the LLM implements the full plan; you can still walk Previous/Next over the diffs, then **Complete**. Send an alternative instruction from the HUD to revise the remaining plan, or **Stop** to end the session.
54
+ A normal chat request does not open a session. Use `/inbase` after Setup LLM session, or `/skipinbase [request]` to work outside the map. `/inbase` starts the session immediately; `wait-for-blueprint` only reads the optional blueprint.
55
55
 
56
- The LLM never writes project files itself. Patches apply when you run or complete steps in the visualizer.
56
+ If several sessions are open, only one session window is shown at a time. Switch tabs so the focused session is the one `/inbase` connects to.
57
+
58
+ Turn on **Make LLM look where I look** if the agent should prefer the island you are standing on and the blocks you are facing. With **Step by step** on, click **Create proposal** to start a step, then **Accept proposal** when the patch is ready. With it off, the LLM implements the full plan; you can still walk Previous/Next over the diffs, then **Accept proposal**. Send an alternative instruction from the HUD to revise the remaining plan, or **Stop** to end the session.
59
+
60
+ When no LLM is currently making changes, turn on **Show branch changes** (or press **G**) to highlight the current git branch against its base — committed, unstaged, and untracked files — instead of LLM patch files. The control is disabled while an LLM session is writing or reviewing a patch.
61
+
62
+ The LLM edits project files after a step is invoked. Inbase records those edits
63
+ as a patch against the snapshot taken at invoke. Those stored patches are the
64
+ session record and are applied on every step update.
57
65
 
58
66
  <br clear="all" />
59
67
 
@@ -65,7 +73,7 @@ The LLM never writes project files itself. Patches apply when you run or complet
65
73
  | `inbase run` | Scan this repo and start the local map |
66
74
  | `inbase run --port 5174` | Start on another port |
67
75
 
68
- Session commands (`start-session`, `wait-for-blueprint`, `report-plan`, `wait-for-approval`, `propose-patch`) are used by the Cursor skill. You do not need to run them yourself.
76
+ Session commands (`attach`, `wait-for-blueprint`, `report-plan`, `wait-for-approval`, `propose-patch`) are used by the Cursor skill. You do not need to run them yourself.
69
77
 
70
78
  ## Editor support
71
79
 
@@ -73,7 +81,7 @@ The map runs in the browser. The LLM plan and patch loop currently works in **Cu
73
81
 
74
82
  ## Language support
75
83
 
76
- Source files (`.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.cjs`) become blocks with **functions**, **classes**, and **variables**, plus import edges from `import` and `require()`. Styles and docs (`.css`, `.scss`, `.html`, `.json`, `.md`) show up as blocks without symbols. Only relative specifiers (`./`, `../`) become edges — package names like `react` or `@angular/core` do not.
84
+ Every text file in the project becomes a block. JavaScript and TypeScript (`.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.cjs`) also get **functions**, **classes**, and **variables**. Relative `import` / `require()` specifiers become edges when they resolve including `.astro` and other JS-style modules. Package names like `react` or `@angular/core` do not. Binary files are skipped.
77
85
 
78
86
  ## How to read the map
79
87
 
@@ -103,3 +111,11 @@ To run the example React app itself:
103
111
  ```bash
104
112
  npm run dev:target
105
113
  ```
114
+
115
+ ## Contributing
116
+
117
+ Issues and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
118
+
119
+ ## License
120
+
121
+ Inbase is open source under the [MIT License](LICENSE).
@@ -2,6 +2,7 @@
2
2
  "name": "@visual-coder/explorer",
3
3
  "private": true,
4
4
  "version": "0.0.1",
5
+ "license": "MIT",
5
6
  "type": "module",
6
7
  "scripts": {
7
8
  "scan": "node scripts/scan-target.mjs",
@@ -0,0 +1,24 @@
1
+ import type { PatchImport, PatchImportAddition, PatchSymbolAddition } from './patch-lib.d.ts'
2
+
3
+ export type BranchChanges = {
4
+ available: boolean
5
+ branch: string | null
6
+ base: string | null
7
+ files: string[]
8
+ creates: string[]
9
+ deletes: string[]
10
+ createFolders: string[]
11
+ createLines: Record<string, number>
12
+ imports: PatchImport[]
13
+ addedFunctions: PatchSymbolAddition[]
14
+ addedVariables: PatchSymbolAddition[]
15
+ addedImports: PatchImportAddition[]
16
+ changedFunctions: PatchSymbolAddition[]
17
+ changedVariables: PatchSymbolAddition[]
18
+ }
19
+
20
+ export function emptyBranchChanges(): BranchChanges
21
+ export function readBranchChanges(
22
+ targetRoot: string,
23
+ knownFileIds?: string[],
24
+ ): BranchChanges
@@ -0,0 +1,237 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import {
5
+ collectCreateFolders,
6
+ extractPatchAdditions,
7
+ extractPatchImports,
8
+ foldersFromFileIds,
9
+ parseUnifiedPatch,
10
+ } from './patch-lib.mjs'
11
+
12
+ const SKIP_DIRS = new Set([
13
+ 'node_modules',
14
+ 'dist',
15
+ 'build',
16
+ 'out',
17
+ 'coverage',
18
+ '.git',
19
+ '.inbase',
20
+ ])
21
+ const SKIP_FILES = new Set(['package-lock.json'])
22
+ const BINARY_PROBE_BYTES = 8000
23
+
24
+ function toPosix(filePath) {
25
+ return filePath.split(path.sep).join('/')
26
+ }
27
+
28
+ function unique(ids) {
29
+ return [...new Set(ids)]
30
+ }
31
+
32
+ function runGit(cwd, args) {
33
+ return spawnSync('git', args, {
34
+ cwd,
35
+ encoding: 'utf8',
36
+ maxBuffer: 32 * 1024 * 1024,
37
+ })
38
+ }
39
+
40
+ function gitTopLevel(fromDir) {
41
+ try {
42
+ const result = runGit(fromDir, ['rev-parse', '--show-toplevel'])
43
+ if (result.status !== 0) return null
44
+ const root = result.stdout.trim()
45
+ return root ? path.resolve(root) : null
46
+ } catch {
47
+ return null
48
+ }
49
+ }
50
+
51
+ function isBinaryFile(filePath) {
52
+ try {
53
+ const fd = fs.openSync(filePath, 'r')
54
+ try {
55
+ const buf = Buffer.alloc(BINARY_PROBE_BYTES)
56
+ const bytes = fs.readSync(fd, buf, 0, buf.length, 0)
57
+ return buf.subarray(0, bytes).includes(0)
58
+ } finally {
59
+ fs.closeSync(fd)
60
+ }
61
+ } catch {
62
+ return true
63
+ }
64
+ }
65
+
66
+ function shouldSkipPath(fileId) {
67
+ const parts = fileId.split('/').filter(Boolean)
68
+ if (parts.some((part) => part.startsWith('.') || SKIP_DIRS.has(part))) {
69
+ return true
70
+ }
71
+ return SKIP_FILES.has(parts.at(-1) ?? '')
72
+ }
73
+
74
+ function currentBranch(cwd) {
75
+ const result = runGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])
76
+ if (result.status !== 0) return null
77
+ const name = result.stdout.trim()
78
+ return name || null
79
+ }
80
+
81
+ function resolveBaseRef(gitRoot) {
82
+ const originHead = runGit(gitRoot, [
83
+ 'symbolic-ref',
84
+ '--quiet',
85
+ 'refs/remotes/origin/HEAD',
86
+ ])
87
+ if (originHead.status === 0) {
88
+ const ref = originHead.stdout.trim().replace(/^refs\/remotes\//, '')
89
+ if (ref) return ref
90
+ }
91
+ for (const name of ['origin/main', 'origin/master', 'main', 'master']) {
92
+ const check = runGit(gitRoot, ['rev-parse', '--verify', '--quiet', name])
93
+ if (check.status === 0 && check.stdout.trim()) return name
94
+ }
95
+ return 'HEAD'
96
+ }
97
+
98
+ function resolveMergeBase(gitRoot, baseRef) {
99
+ const result = runGit(gitRoot, ['merge-base', 'HEAD', baseRef])
100
+ if (result.status === 0 && result.stdout.trim()) return result.stdout.trim()
101
+ return 'HEAD'
102
+ }
103
+
104
+ function parseNameStatus(text) {
105
+ const files = []
106
+ const creates = []
107
+ const deletes = []
108
+ for (const line of text.split('\n')) {
109
+ if (!line.trim()) continue
110
+ const tab = line.indexOf('\t')
111
+ if (tab < 0) continue
112
+ const status = line.slice(0, tab).trim()
113
+ const filePath = toPosix(line.slice(tab + 1).trim())
114
+ if (!filePath || filePath.includes('\t') || shouldSkipPath(filePath)) continue
115
+ const code = status[0]
116
+ if (code === 'A') creates.push(filePath)
117
+ else if (code === 'D') deletes.push(filePath)
118
+ else if (code === 'M' || code === 'T') files.push(filePath)
119
+ }
120
+ return { files, creates, deletes }
121
+ }
122
+
123
+ function fileAsAddPatch(fileId, contents) {
124
+ const lines = contents.split('\n')
125
+ if (contents.endsWith('\n')) lines.pop()
126
+ const count = lines.length
127
+ const hunk =
128
+ count === 0
129
+ ? []
130
+ : [`@@ -0,0 +1,${count} @@`, ...lines.map((line) => `+${line}`)]
131
+ return [
132
+ `diff --git a/${fileId} b/${fileId}`,
133
+ 'new file mode 100644',
134
+ '--- /dev/null',
135
+ `+++ b/${fileId}`,
136
+ ...hunk,
137
+ '',
138
+ ].join('\n')
139
+ }
140
+
141
+ function collectUntracked(targetRoot) {
142
+ const result = runGit(targetRoot, ['ls-files', '--others', '--exclude-standard'])
143
+ const creates = []
144
+ const patches = []
145
+ const createLines = {}
146
+ if (result.status !== 0) return { creates, patches, createLines }
147
+
148
+ for (const raw of result.stdout.split('\n')) {
149
+ const fileId = toPosix(raw.trim())
150
+ if (!fileId || shouldSkipPath(fileId)) continue
151
+ const absolute = path.join(targetRoot, fileId)
152
+ try {
153
+ if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) continue
154
+ } catch {
155
+ continue
156
+ }
157
+ if (isBinaryFile(absolute)) continue
158
+ const contents = fs.readFileSync(absolute, 'utf8')
159
+ creates.push(fileId)
160
+ patches.push(fileAsAddPatch(fileId, contents))
161
+ const lineCount =
162
+ contents === ''
163
+ ? 1
164
+ : contents.split('\n').length - (contents.endsWith('\n') ? 1 : 0)
165
+ createLines[fileId] = Math.max(1, lineCount)
166
+ }
167
+ return { creates, patches, createLines }
168
+ }
169
+
170
+ export function emptyBranchChanges() {
171
+ return {
172
+ available: false,
173
+ branch: null,
174
+ base: null,
175
+ files: [],
176
+ creates: [],
177
+ deletes: [],
178
+ createFolders: [],
179
+ createLines: {},
180
+ imports: [],
181
+ addedFunctions: [],
182
+ addedVariables: [],
183
+ addedImports: [],
184
+ changedFunctions: [],
185
+ changedVariables: [],
186
+ }
187
+ }
188
+
189
+ export function readBranchChanges(targetRoot, knownFileIds = []) {
190
+ const empty = emptyBranchChanges()
191
+ if (!targetRoot || !fs.existsSync(targetRoot)) return empty
192
+ const gitRoot = gitTopLevel(targetRoot)
193
+ if (!gitRoot) return empty
194
+
195
+ const branch = currentBranch(targetRoot) ?? currentBranch(gitRoot)
196
+ const base = resolveBaseRef(gitRoot)
197
+ const mergeBase = resolveMergeBase(gitRoot, base)
198
+ const diffArgs = [
199
+ '--no-color',
200
+ '--no-ext-diff',
201
+ '--no-renames',
202
+ '--relative',
203
+ mergeBase,
204
+ ]
205
+ const statusResult = runGit(targetRoot, ['diff', '--name-status', ...diffArgs])
206
+ const patchResult = runGit(targetRoot, ['diff', ...diffArgs])
207
+ const named = parseNameStatus(
208
+ statusResult.status === 0 ? statusResult.stdout : '',
209
+ )
210
+ const untracked = collectUntracked(targetRoot)
211
+ const creates = unique([...named.creates, ...untracked.creates])
212
+ const deletes = named.deletes.filter((id) => !creates.includes(id))
213
+ const files = named.files.filter(
214
+ (id) => !creates.includes(id) && !deletes.includes(id),
215
+ )
216
+ const patchText = [patchResult.status === 0 ? patchResult.stdout : '', ...untracked.patches]
217
+ .filter((part) => part.trim())
218
+ .join('\n')
219
+ const parsed = parseUnifiedPatch(patchText)
220
+ const known = unique([...knownFileIds, ...creates, ...files])
221
+
222
+ return {
223
+ available: true,
224
+ branch,
225
+ base,
226
+ files,
227
+ creates,
228
+ deletes,
229
+ createFolders: collectCreateFolders(
230
+ creates,
231
+ foldersFromFileIds(knownFileIds.filter((id) => !creates.includes(id))),
232
+ ),
233
+ createLines: { ...parsed.createLines, ...untracked.createLines },
234
+ imports: extractPatchImports(parsed.entries, known),
235
+ ...extractPatchAdditions(parsed.entries),
236
+ }
237
+ }
@@ -1,17 +1,3 @@
1
- export const FILE_EXTENSIONS = new Set([
2
- '.ts',
3
- '.tsx',
4
- '.js',
5
- '.jsx',
6
- '.mjs',
7
- '.cjs',
8
- '.css',
9
- '.scss',
10
- '.json',
11
- '.html',
12
- '.md',
13
- ])
14
-
15
1
  export const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])
16
2
 
17
3
  export const RESOLVE_EXTENSIONS = [
@@ -147,16 +133,27 @@ export function extractImportBindings(source) {
147
133
  return bindings
148
134
  }
149
135
 
136
+ function firstKnownWithPrefix(prefix, known) {
137
+ for (const id of known) {
138
+ if (!id.startsWith(prefix)) continue
139
+ if (id.slice(prefix.length).includes('/')) continue
140
+ return id
141
+ }
142
+ return null
143
+ }
144
+
150
145
  export function resolveSpecifierAgainst(candidate, known) {
151
146
  if (known.has(candidate)) return candidate
152
147
  for (const ext of RESOLVE_EXTENSIONS) {
153
148
  if (known.has(candidate + ext)) return candidate + ext
154
149
  }
150
+ const withExt = firstKnownWithPrefix(`${candidate}.`, known)
151
+ if (withExt) return withExt
155
152
  for (const ext of RESOLVE_EXTENSIONS) {
156
153
  const indexFile = `${candidate}/index${ext}`
157
154
  if (known.has(indexFile)) return indexFile
158
155
  }
159
- return null
156
+ return firstKnownWithPrefix(`${candidate}/index.`, known)
160
157
  }
161
158
 
162
159
  function destructuredNames(pattern, { importClause = false } = {}) {
@@ -80,6 +80,7 @@ export const emptyIntent: {
80
80
  updatedAt: null
81
81
  showMap: boolean
82
82
  status: string
83
+ name: null
83
84
  feature: null
84
85
  steps: unknown[]
85
86
  step: null
@@ -106,6 +107,11 @@ export const emptyIntent: {
106
107
  phase: null
107
108
  working: boolean
108
109
  stalledWait: boolean
110
+ llmIdle: boolean
111
+ awaitingAttach: boolean
112
+ listening: boolean
113
+ lastAck: { kind: string; detail: string; at: string | null } | null
114
+ initialInstruction: string | null
109
115
  creationMode: boolean
110
116
  canEnterBlueprint: boolean
111
117
  blueprintSessionId: string | null
@@ -507,6 +507,7 @@ export const emptyIntent = {
507
507
  updatedAt: null,
508
508
  showMap: false,
509
509
  status: 'idle',
510
+ name: null,
510
511
  feature: null,
511
512
  steps: [],
512
513
  step: null,
@@ -533,6 +534,11 @@ export const emptyIntent = {
533
534
  phase: null,
534
535
  working: false,
535
536
  stalledWait: false,
537
+ llmIdle: false,
538
+ awaitingAttach: false,
539
+ listening: false,
540
+ lastAck: null,
541
+ initialInstruction: null,
536
542
  creationMode: false,
537
543
  canEnterBlueprint: false,
538
544
  blueprintSessionId: null,
@@ -2,7 +2,6 @@ import fs from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
4
  import {
5
- FILE_EXTENSIONS,
6
5
  SOURCE_EXTENSIONS,
7
6
  RESOLVE_EXTENSIONS,
8
7
  collectImportSpecifiers,
@@ -25,11 +24,23 @@ const IGNORE_DIRS = new Set([
25
24
  '.inbase',
26
25
  ])
27
26
  const IGNORE_FILES = new Set(['package-lock.json'])
27
+ const BINARY_PROBE_BYTES = 8000
28
28
 
29
29
  function toPosix(filePath) {
30
30
  return filePath.split(path.sep).join('/')
31
31
  }
32
32
 
33
+ function isBinaryFile(filePath) {
34
+ const fd = fs.openSync(filePath, 'r')
35
+ try {
36
+ const buf = Buffer.alloc(BINARY_PROBE_BYTES)
37
+ const bytes = fs.readSync(fd, buf, 0, buf.length, 0)
38
+ return buf.subarray(0, bytes).includes(0)
39
+ } finally {
40
+ fs.closeSync(fd)
41
+ }
42
+ }
43
+
33
44
  function walk(dir, acc = []) {
34
45
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
35
46
  if (entry.name.startsWith('.')) continue
@@ -39,8 +50,9 @@ function walk(dir, acc = []) {
39
50
  continue
40
51
  }
41
52
  if (IGNORE_FILES.has(entry.name)) continue
42
- if (!FILE_EXTENSIONS.has(path.extname(entry.name))) continue
43
- acc.push(path.join(dir, entry.name))
53
+ const absolutePath = path.join(dir, entry.name)
54
+ if (isBinaryFile(absolutePath)) continue
55
+ acc.push(absolutePath)
44
56
  }
45
57
  return acc
46
58
  }
@@ -55,24 +67,40 @@ function extractSymbols(source, ext) {
55
67
  return extractJsSymbols(source)
56
68
  }
57
69
 
58
- function resolveExisting(candidate) {
59
- if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
60
- return candidate
70
+ function isUsableFile(filePath) {
71
+ return fs.existsSync(filePath) && fs.statSync(filePath).isFile() && !isBinaryFile(filePath)
72
+ }
73
+
74
+ function fileWithStem(dir, stem) {
75
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return null
76
+ for (const name of fs.readdirSync(dir)) {
77
+ if (name.startsWith('.')) continue
78
+ const parsed = path.parse(name)
79
+ if (parsed.name !== stem || !parsed.ext) continue
80
+ const full = path.join(dir, name)
81
+ if (isUsableFile(full)) return full
61
82
  }
83
+ return null
84
+ }
85
+
86
+ function resolveExisting(candidate) {
87
+ if (isUsableFile(candidate)) return candidate
62
88
 
63
89
  for (const ext of RESOLVE_EXTENSIONS) {
64
90
  const withExt = candidate + ext
65
- if (fs.existsSync(withExt) && fs.statSync(withExt).isFile()) {
66
- return withExt
67
- }
91
+ if (isUsableFile(withExt)) return withExt
68
92
  }
69
93
 
70
- const indexDir = candidate
71
- if (fs.existsSync(indexDir) && fs.statSync(indexDir).isDirectory()) {
94
+ const sibling = fileWithStem(path.dirname(candidate), path.basename(candidate))
95
+ if (sibling) return sibling
96
+
97
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
72
98
  for (const ext of RESOLVE_EXTENSIONS) {
73
- const indexFile = path.join(indexDir, `index${ext}`)
74
- if (fs.existsSync(indexFile)) return indexFile
99
+ const indexFile = path.join(candidate, `index${ext}`)
100
+ if (isUsableFile(indexFile)) return indexFile
75
101
  }
102
+ const indexFile = fileWithStem(candidate, 'index')
103
+ if (indexFile) return indexFile
76
104
  }
77
105
 
78
106
  return null
@@ -110,6 +138,11 @@ function ensureFolder(folders, folderPath, rootName) {
110
138
  if (parent) ensureFolder(folders, parent, rootName)
111
139
  }
112
140
 
141
+ export function listSourceFiles(root) {
142
+ if (!root || !fs.existsSync(root) || !fs.statSync(root).isDirectory()) return []
143
+ return walk(root).map((absolutePath) => toPosix(path.relative(root, absolutePath)))
144
+ }
145
+
113
146
  export function scanTarget({
114
147
  root = defaultTargetRoot,
115
148
  name = path.basename(root),