totopo 0.5.0 → 0.6.0-rc-2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "totopo",
3
- "version": "0.5.0",
3
+ "version": "0.6.0-rc-2",
4
4
  "description": "Secure AI Box — isolated dev environments for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,10 +5,10 @@
5
5
  // =========================================================================================================================================
6
6
 
7
7
  import { spawnSync } from "node:child_process";
8
- import { existsSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
8
+ import { existsSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { basename, join } from "node:path";
11
- import { cancel, isCancel, log, multiselect, outro, select } from "@clack/prompts";
11
+ import { cancel, groupMultiselect, isCancel, log, multiselect, outro, path, select } from "@clack/prompts";
12
12
 
13
13
  // biome-ignore lint/style/noNonNullAssertion: guarded immediately below; non-null assertion needed for closure type inference
14
14
  const workspaceDir = process.env.TOTOPO_REPO_ROOT!;
@@ -66,11 +66,60 @@ async function promptScope(): Promise<ScopeConfig> {
66
66
  }
67
67
 
68
68
  // ─── Prompt: selective path selection ─────────────────────────────────────────
69
+ // Recursively expands a selected path into its children when a nested exclusion target is found,
70
+ // until the excluded path itself can be dropped from the list.
71
+ function expandExclusion(paths: string[], excl: string): string[] {
72
+ if (paths.includes(excl)) {
73
+ return paths.filter((p) => p !== excl);
74
+ }
75
+ const ancestor = paths.find((p) => excl.startsWith(`${p}/`));
76
+ if (!ancestor) {
77
+ log.warn(`Cannot exclude "${excl}" — it is not within any selected path. Skipping.`);
78
+ return paths;
79
+ }
80
+ const children = readdirSync(join(cwd, ancestor)).map((child) => `${ancestor}/${child}`);
81
+ const withoutAncestor = paths.filter((p) => p !== ancestor);
82
+ return expandExclusion([...withoutAncestor, ...children], excl);
83
+ }
84
+
85
+ function scanCwdDepth2(): { dirs: Record<string, string[]>; files: string[] } {
86
+ const dirs: Record<string, string[]> = {};
87
+ const files: string[] = [];
88
+
89
+ for (const item of readdirSync(cwd)) {
90
+ const itemPath = join(cwd, item);
91
+ if (statSync(itemPath).isDirectory()) {
92
+ const children = readdirSync(itemPath).map((child) => `${item}/${child}`);
93
+ if (children.length === 0) {
94
+ files.push(item); // empty dir → treat as flat item
95
+ } else {
96
+ dirs[item] = children;
97
+ }
98
+ } else {
99
+ files.push(item);
100
+ }
101
+ }
102
+
103
+ return { dirs, files };
104
+ }
105
+
106
+ function normalizeSelection(selected: string[], dirNames: string[]): string[] {
107
+ const withoutGroupKey = selected.filter((s) => s !== "Files");
108
+ const dirSet = new Set(dirNames);
109
+
110
+ return withoutGroupKey.filter((s) => {
111
+ const slashIdx = s.indexOf("/");
112
+ if (slashIdx === -1) return true; // depth-1 item, always keep
113
+ const parent = s.slice(0, slashIdx);
114
+ return !(dirSet.has(parent) && withoutGroupKey.includes(parent));
115
+ });
116
+ }
117
+
69
118
  async function promptSelectivePaths(): Promise<string[]> {
70
- const allItems = readdirSync(cwd).filter((f) => !f.startsWith("."));
119
+ const allItems = readdirSync(cwd);
71
120
 
72
121
  if (allItems.length === 0) {
73
- log.warn("No visible files/folders in current directory — falling back to cwd mode.");
122
+ log.warn("No files/folders in current directory — falling back to cwd mode.");
74
123
  return [];
75
124
  }
76
125
 
@@ -88,21 +137,105 @@ async function promptSelectivePaths(): Promise<string[]> {
88
137
  }
89
138
 
90
139
  const style = styleChoice as "only" | "except";
91
- const initialValues = style === "except" ? allItems : [];
140
+ const { dirs, files } = scanCwdDepth2();
141
+ const dirNames = Object.keys(dirs);
142
+
143
+ if (style === "only") {
144
+ log.info(
145
+ "Tip: check a folder header to include everything inside it, or expand it to pick specific files. Use the next step for paths deeper than what's shown.",
146
+ );
147
+ } else {
148
+ log.info(
149
+ "Tip: everything is selected at the folder level — uncheck a folder header to exclude it entirely, or expand it to exclude specific files. Use the next step for deeper paths.",
150
+ );
151
+ }
92
152
 
93
- const selected = await multiselect({
153
+ // ── flat fallback when there are no dirs ──────────────────────────────────
154
+ if (dirNames.length === 0) {
155
+ const flatSelected = await multiselect({
156
+ message: "Choose paths:",
157
+ options: files.map((f) => ({ value: f, label: f })),
158
+ initialValues: style === "except" ? files : [],
159
+ required: true,
160
+ });
161
+
162
+ if (isCancel(flatSelected)) {
163
+ cancel("Cancelled.");
164
+ process.exit(0);
165
+ }
166
+
167
+ return flatSelected as string[];
168
+ }
169
+
170
+ // ── build groupMultiselect options ────────────────────────────────────────
171
+ const groupOptions: Record<string, { value: string; label: string }[]> = {};
172
+ for (const [dir, children] of Object.entries(dirs)) {
173
+ groupOptions[dir] = children.map((child) => ({
174
+ value: child,
175
+ label: child.slice(child.indexOf("/") + 1),
176
+ }));
177
+ }
178
+ if (files.length > 0) {
179
+ groupOptions.Files = files.map((f) => ({ value: f, label: f }));
180
+ }
181
+
182
+ // "except" → pre-select dir headers + root files (depth-1 only)
183
+ const initialValues = style === "except" ? [...dirNames, ...files] : [];
184
+
185
+ const rawSelected = await groupMultiselect({
94
186
  message: "Choose paths:",
95
- options: allItems.map((item) => ({ value: item, label: item })),
187
+ options: groupOptions,
96
188
  initialValues,
97
189
  required: true,
190
+ selectableGroups: true,
98
191
  });
99
192
 
100
- if (isCancel(selected)) {
193
+ if (isCancel(rawSelected)) {
101
194
  cancel("Cancelled.");
102
195
  process.exit(0);
103
196
  }
104
197
 
105
- return selected as string[];
198
+ const selected = normalizeSelection(rawSelected as string[], dirNames);
199
+
200
+ // ── path prompt loop for depth-3+ targets ────────────────────────────────
201
+ // Repeats until the user presses Enter on an empty input.
202
+ let result = selected;
203
+ const promptMsg =
204
+ style === "only" ? "Add a deeper path to include (press Enter to finish):" : "Exclude a deeper path (press Enter to finish):";
205
+
206
+ while (true) {
207
+ const deepPathRaw = await path({
208
+ message: promptMsg,
209
+ root: cwd,
210
+ validate: (value) => {
211
+ if (!value) return undefined; // empty = done, always valid
212
+ const relative = value.startsWith(`${cwd}/`) ? value.slice(cwd.length + 1) : value;
213
+ if (!relative) return "Path cannot be empty.";
214
+ if (!existsSync(join(cwd, relative))) return `Path not found: ${relative}`;
215
+ return undefined;
216
+ },
217
+ });
218
+
219
+ if (isCancel(deepPathRaw)) {
220
+ cancel("Cancelled.");
221
+ process.exit(0);
222
+ }
223
+
224
+ const deepPathAbsolute = deepPathRaw as string;
225
+ if (!deepPathAbsolute) break; // user skipped — done
226
+
227
+ const deepPath = deepPathAbsolute.startsWith(`${cwd}/`) ? deepPathAbsolute.slice(cwd.length + 1) : deepPathAbsolute;
228
+
229
+ if (!deepPath) break;
230
+
231
+ if (style === "only") {
232
+ result = [...new Set([...result, deepPath])];
233
+ } else {
234
+ result = expandExclusion(result, deepPath);
235
+ }
236
+ }
237
+
238
+ return result;
106
239
  }
107
240
 
108
241
  // ─── Totopo mount path inside container ──────────────────────────────────────