schub 0.1.0 → 0.1.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.
Files changed (50) hide show
  1. package/README.md +68 -0
  2. package/dist/index.js +1573 -597
  3. package/package.json +3 -1
  4. package/skills/create-proposal/SKILL.md +33 -0
  5. package/skills/create-tasks/SKILL.md +40 -0
  6. package/skills/implement-task/SKILL.md +84 -0
  7. package/skills/review-proposal/SKILL.md +37 -0
  8. package/skills/setup-project/SKILL.md +29 -0
  9. package/src/App.test.tsx +93 -0
  10. package/src/App.tsx +62 -10
  11. package/src/changes.ts +86 -28
  12. package/src/clipboard.ts +5 -0
  13. package/src/commands/adr.test.ts +69 -0
  14. package/src/commands/adr.ts +107 -0
  15. package/src/commands/changes.test.ts +171 -0
  16. package/src/commands/changes.ts +163 -0
  17. package/src/commands/cookbook.test.ts +71 -0
  18. package/src/commands/cookbook.ts +95 -0
  19. package/src/commands/eject.test.ts +74 -0
  20. package/src/commands/eject.ts +100 -0
  21. package/src/commands/init.test.ts +78 -0
  22. package/src/commands/init.ts +144 -0
  23. package/src/commands/project.test.ts +113 -0
  24. package/src/commands/project.ts +75 -0
  25. package/src/commands/review.test.ts +100 -0
  26. package/src/commands/review.ts +231 -0
  27. package/src/commands/tasks-create.test.ts +172 -0
  28. package/src/commands/tasks-list.test.ts +177 -0
  29. package/src/commands/tasks.ts +172 -0
  30. package/src/components/PlanView.test.tsx +113 -0
  31. package/src/components/PlanView.tsx +95 -26
  32. package/src/components/StatusView.test.tsx +380 -0
  33. package/src/components/StatusView.tsx +233 -83
  34. package/src/features/tasks/constants.ts +2 -0
  35. package/src/features/tasks/create.ts +15 -7
  36. package/src/features/tasks/filesystem.test.ts +78 -0
  37. package/src/features/tasks/filesystem.ts +61 -7
  38. package/src/ide.ts +7 -0
  39. package/src/index.test.ts +23 -0
  40. package/src/index.ts +60 -383
  41. package/src/init.test.ts +43 -0
  42. package/src/init.ts +27 -0
  43. package/src/project.ts +5 -32
  44. package/src/schub-root.ts +33 -0
  45. package/src/templates.ts +18 -0
  46. package/src/terminal.test.ts +46 -0
  47. package/templates/create-proposal/cookbook-template.md +37 -0
  48. package/templates/review-proposal/q&a-template.md +5 -1
  49. package/templates/templates-parity.test.ts +45 -0
  50. package/templates/setup-project/review-me-template.md +0 -18
package/src/index.ts CHANGED
@@ -1,391 +1,44 @@
1
1
  #!/usr/bin/env bun
2
- import { dirname } from "node:path";
3
- import { spawnSync } from "bun";
4
2
  import { render } from "ink";
5
3
  import React from "react";
6
4
  import App from "./App";
7
- import { createChange, resolveChangeRoot } from "./changes";
8
- import { createTask, findSchubRoot, listTasks, TASK_STATUSES, type TaskStatus } from "./features/tasks";
9
- import { createProject } from "./project";
5
+ import { runAdrCreate } from "./commands/adr";
6
+ import { runChangesCreate, runChangesStatus } from "./commands/changes";
7
+ import { runCookbookCreate } from "./commands/cookbook";
8
+ import { runEject } from "./commands/eject";
9
+ import { runInit } from "./commands/init";
10
+ import { runProjectCreate } from "./commands/project";
11
+ import { runReviewComplete, runReviewCreate } from "./commands/review";
12
+ import { runTasksCreate, runTasksList } from "./commands/tasks";
13
+ import { findSchubRoot } from "./features/tasks";
10
14
  import { applyTerminalPrelude, applyTerminalReset } from "./terminal";
11
15
 
12
16
  const HELP_TEXT = `schub [command]
13
17
 
14
18
  Commands:
15
- changes create Create a change proposal
16
- project create Create project docs
17
- tasks create Create task files for a change
18
- tasks list List tasks
19
- lint Run Biome lint checks
20
- format Format files with Biome
19
+ changes create Create a change proposal
20
+ changes status Update change proposal status
21
+ project create Create project docs
22
+ tasks create Create task files for a change
23
+ tasks list List tasks
24
+ review create Create REVIEW_ME for a change
25
+ review complete Create Q&A from REVIEW_ME
26
+ adr create Create an ADR for a change
27
+ cookbook create Create a cookbook for a change
28
+ init Initialize .schub and install Codex skills
29
+ eject Copy bundled skills and templates into .schub
21
30
  ui Launch the interactive dashboard
22
31
  `;
23
32
 
24
- const getStartDir = (): string => process.env.SCHUB_CWD ?? process.cwd();
33
+ const getStartDir = () => process.env.SCHUB_CWD ?? process.cwd();
25
34
 
26
- const getWorkspaceRoot = (): string => {
27
- const schubDir = findSchubRoot(getStartDir());
28
- return schubDir ? dirname(schubDir) : getStartDir();
29
- };
30
-
31
- const resolveSchubDir = (): string | null => findSchubRoot(getStartDir());
35
+ const resolveSchubDir = () => findSchubRoot(getStartDir());
32
36
 
33
- const printHelp = (exitCode = 0): void => {
37
+ const printHelp = (exitCode = 0) => {
34
38
  process.stdout.write(`${HELP_TEXT}\n`);
35
39
  process.exitCode = exitCode;
36
40
  };
37
41
 
38
- const parseStatusFilter = (value: string | undefined): TaskStatus[] => {
39
- if (!value) {
40
- return [...TASK_STATUSES];
41
- }
42
-
43
- const normalized = value
44
- .split(",")
45
- .map((status) => status.trim().toLowerCase())
46
- .filter(Boolean);
47
-
48
- const allowed = new Set(TASK_STATUSES);
49
- const invalid = normalized.filter((status) => !allowed.has(status as TaskStatus));
50
- if (invalid.length > 0) {
51
- throw new Error(`Unknown status filter: ${invalid.join(", ")}`);
52
- }
53
-
54
- return normalized as TaskStatus[];
55
- };
56
-
57
- type TaskListOptions = {
58
- statuses: TaskStatus[];
59
- json: boolean;
60
- };
61
-
62
- const parseTaskListOptions = (args: string[]): TaskListOptions => {
63
- let statusValue: string | undefined;
64
- let json = false;
65
- const unknown: string[] = [];
66
-
67
- for (let index = 0; index < args.length; index += 1) {
68
- const arg = args[index];
69
- if (arg === "--json") {
70
- json = true;
71
- continue;
72
- }
73
- if (arg === "--status") {
74
- statusValue = args[index + 1];
75
- if (!statusValue) {
76
- throw new Error("Missing value for --status.");
77
- }
78
- index += 1;
79
- continue;
80
- }
81
- if (arg.startsWith("--status=")) {
82
- statusValue = arg.slice("--status=".length);
83
- continue;
84
- }
85
- unknown.push(arg);
86
- }
87
-
88
- if (unknown.length > 0) {
89
- throw new Error(`Unknown option(s): ${unknown.join(", ")}`);
90
- }
91
-
92
- return { statuses: parseStatusFilter(statusValue), json };
93
- };
94
-
95
- type ChangeCreateOptions = {
96
- changeId?: string;
97
- title?: string;
98
- input?: string;
99
- overwrite: boolean;
100
- };
101
-
102
- type ProjectCreateOptions = {
103
- repoRoot?: string;
104
- projectName?: string;
105
- overwrite: boolean;
106
- };
107
-
108
- const parseChangeCreateOptions = (args: string[]) => {
109
- let changeId: string | undefined;
110
- let title: string | undefined;
111
- let input: string | undefined;
112
- let overwrite = false;
113
- const unknown: string[] = [];
114
-
115
- const rejectUnsupported = (flag: string) => {
116
- throw new Error(`Unsupported option: ${flag}.`);
117
- };
118
-
119
- for (let index = 0; index < args.length; index += 1) {
120
- const arg = args[index];
121
- if (arg === "--overwrite") {
122
- overwrite = true;
123
- continue;
124
- }
125
- if (arg === "--change-id") {
126
- changeId = args[index + 1];
127
- if (changeId === undefined) {
128
- throw new Error("Missing value for --change-id.");
129
- }
130
- index += 1;
131
- continue;
132
- }
133
- if (arg.startsWith("--change-id=")) {
134
- changeId = arg.slice("--change-id=".length);
135
- continue;
136
- }
137
- if (arg === "--title") {
138
- title = args[index + 1];
139
- if (title === undefined) {
140
- throw new Error("Missing value for --title.");
141
- }
142
- index += 1;
143
- continue;
144
- }
145
- if (arg.startsWith("--title=")) {
146
- title = arg.slice("--title=".length);
147
- continue;
148
- }
149
- if (arg === "--input") {
150
- input = args[index + 1];
151
- if (input === undefined) {
152
- throw new Error("Missing value for --input.");
153
- }
154
- index += 1;
155
- continue;
156
- }
157
- if (arg.startsWith("--input=")) {
158
- input = arg.slice("--input=".length);
159
- continue;
160
- }
161
- if (arg === "--schub-root" || arg === "--agent-root") {
162
- rejectUnsupported(arg);
163
- }
164
- if (arg.startsWith("--schub-root=")) {
165
- rejectUnsupported("--schub-root");
166
- }
167
- if (arg.startsWith("--agent-root=")) {
168
- rejectUnsupported("--agent-root");
169
- }
170
- unknown.push(arg);
171
- }
172
-
173
- if (unknown.length > 0) {
174
- throw new Error(`Unknown option(s): ${unknown.join(", ")}`);
175
- }
176
-
177
- const options: ChangeCreateOptions = { changeId, title, input, overwrite };
178
- return options;
179
- };
180
-
181
- const parseProjectCreateOptions = (args: string[]) => {
182
- let repoRoot: string | undefined;
183
- let projectName: string | undefined;
184
- let overwrite = false;
185
- const unknown: string[] = [];
186
-
187
- const rejectUnsupported = (flag: string) => {
188
- throw new Error(`Unsupported option: ${flag}.`);
189
- };
190
-
191
- for (let index = 0; index < args.length; index += 1) {
192
- const arg = args[index];
193
- if (arg === "--overwrite") {
194
- overwrite = true;
195
- continue;
196
- }
197
- if (arg === "--repo-root") {
198
- repoRoot = args[index + 1];
199
- if (repoRoot === undefined) {
200
- throw new Error("Missing value for --repo-root.");
201
- }
202
- index += 1;
203
- continue;
204
- }
205
- if (arg.startsWith("--repo-root=")) {
206
- repoRoot = arg.slice("--repo-root=".length);
207
- continue;
208
- }
209
- if (arg === "--project-name") {
210
- projectName = args[index + 1];
211
- if (projectName === undefined) {
212
- throw new Error("Missing value for --project-name.");
213
- }
214
- index += 1;
215
- continue;
216
- }
217
- if (arg.startsWith("--project-name=")) {
218
- projectName = arg.slice("--project-name=".length);
219
- continue;
220
- }
221
- if (arg === "--schub-root" || arg === "--agent-root") {
222
- rejectUnsupported(arg);
223
- }
224
- if (arg.startsWith("--schub-root=")) {
225
- rejectUnsupported("--schub-root");
226
- }
227
- if (arg.startsWith("--agent-root=")) {
228
- rejectUnsupported("--agent-root");
229
- }
230
- unknown.push(arg);
231
- }
232
-
233
- if (unknown.length > 0) {
234
- throw new Error(`Unknown option(s): ${unknown.join(", ")}`);
235
- }
236
-
237
- return { repoRoot, projectName, overwrite };
238
- };
239
-
240
- type TaskCreateOptions = {
241
- changeId: string;
242
- status?: string;
243
- titles: string[];
244
- overwrite: boolean;
245
- };
246
-
247
- const parseTaskCreateOptions = (args: string[]) => {
248
- let changeId: string | undefined;
249
- let status: string | undefined;
250
- let overwrite = false;
251
- const titles: string[] = [];
252
- const unknown: string[] = [];
253
-
254
- const rejectUnsupported = (flag: string) => {
255
- throw new Error(`Unsupported option: ${flag}.`);
256
- };
257
-
258
- for (let index = 0; index < args.length; index += 1) {
259
- const arg = args[index];
260
- if (arg === "--overwrite") {
261
- overwrite = true;
262
- continue;
263
- }
264
- if (arg === "--change-id") {
265
- changeId = args[index + 1];
266
- if (changeId === undefined) {
267
- throw new Error("Missing value for --change-id.");
268
- }
269
- index += 1;
270
- continue;
271
- }
272
- if (arg.startsWith("--change-id=")) {
273
- changeId = arg.slice("--change-id=".length);
274
- continue;
275
- }
276
- if (arg === "--status") {
277
- status = args[index + 1];
278
- if (status === undefined) {
279
- throw new Error("Missing value for --status.");
280
- }
281
- index += 1;
282
- continue;
283
- }
284
- if (arg.startsWith("--status=")) {
285
- status = arg.slice("--status=".length);
286
- continue;
287
- }
288
- if (arg === "--title") {
289
- const title = args[index + 1];
290
- if (title === undefined) {
291
- throw new Error("Missing value for --title.");
292
- }
293
- titles.push(title);
294
- index += 1;
295
- continue;
296
- }
297
- if (arg.startsWith("--title=")) {
298
- titles.push(arg.slice("--title=".length));
299
- continue;
300
- }
301
- if (arg === "--schub-root" || arg === "--agent-root") {
302
- rejectUnsupported(arg);
303
- }
304
- if (arg.startsWith("--schub-root=")) {
305
- rejectUnsupported("--schub-root");
306
- }
307
- if (arg.startsWith("--agent-root=")) {
308
- rejectUnsupported("--agent-root");
309
- }
310
- unknown.push(arg);
311
- }
312
-
313
- if (unknown.length > 0) {
314
- throw new Error(`Unknown option(s): ${unknown.join(", ")}`);
315
- }
316
-
317
- if (!changeId) {
318
- throw new Error("Provide --change-id.");
319
- }
320
-
321
- const options: TaskCreateOptions = { changeId, status, titles, overwrite };
322
- return options;
323
- };
324
-
325
- const runTasksList = (args: string[]): void => {
326
- const schubDir = resolveSchubDir();
327
- if (!schubDir) {
328
- throw new Error("No .schub directory found.");
329
- }
330
-
331
- const options = parseTaskListOptions(args);
332
- const tasks = listTasks(schubDir, options.statuses);
333
-
334
- if (options.json) {
335
- process.stdout.write(`${JSON.stringify(tasks, null, 2)}\n`);
336
- return;
337
- }
338
-
339
- const lines = tasks.map((task) => `${task.id} ${task.title} (${task.status})`);
340
- process.stdout.write(`${lines.join("\n")}\n`);
341
- };
342
-
343
- const runTasksCreate = (args: string[]) => {
344
- const options = parseTaskCreateOptions(args);
345
- const schubDir = resolveChangeRoot(getStartDir());
346
- const created = createTask(schubDir, options);
347
-
348
- for (const taskPath of created) {
349
- process.stdout.write(`[OK] Wrote task: ${taskPath}\n`);
350
- }
351
- };
352
-
353
- const runBiome = (command: "lint" | "format"): void => {
354
- const workspaceRoot = getWorkspaceRoot();
355
- const biomeArgs = command === "lint" ? ["lint", "."] : ["format", ".", "--write"];
356
- const result = spawnSync({
357
- cmd: ["bunx", "@biomejs/biome", ...biomeArgs],
358
- cwd: workspaceRoot,
359
- env: process.env,
360
- });
361
-
362
- if (result.stdout && result.stdout.length > 0) {
363
- process.stdout.write(result.stdout);
364
- }
365
-
366
- if (result.stderr && result.stderr.length > 0) {
367
- process.stderr.write(result.stderr);
368
- }
369
-
370
- process.exitCode = result.exitCode ?? 1;
371
- };
372
-
373
- const runChangesCreate = (args: string[]) => {
374
- const options = parseChangeCreateOptions(args);
375
- const schubDir = resolveChangeRoot(getStartDir());
376
- const proposalPath = createChange(schubDir, options);
377
- process.stdout.write(`[OK] Wrote proposal: ${proposalPath}\n`);
378
- };
379
-
380
- const runProjectCreate = (args: string[]) => {
381
- const options = parseProjectCreateOptions(args);
382
- const outputs = createProject(getStartDir(), options);
383
-
384
- for (const output of outputs) {
385
- process.stdout.write(`[OK] Wrote ${output}\n`);
386
- }
387
- };
388
-
389
42
  const registerTerminalReset = () => {
390
43
  process.once("exit", () => {
391
44
  applyTerminalReset();
@@ -398,7 +51,7 @@ const runUi = () => {
398
51
  render(React.createElement(App));
399
52
  };
400
53
 
401
- const runCommand = (): void => {
54
+ const runCommand = async () => {
402
55
  const args = process.argv.slice(2);
403
56
  if (args.length === 0) {
404
57
  runUi();
@@ -415,31 +68,57 @@ const runCommand = (): void => {
415
68
  switch (primary) {
416
69
  case "changes":
417
70
  if (secondary === "create") {
418
- runChangesCreate(rest);
71
+ runChangesCreate(rest, getStartDir());
72
+ return;
73
+ }
74
+ if (secondary === "status") {
75
+ runChangesStatus(rest, getStartDir());
419
76
  return;
420
77
  }
421
78
  break;
422
79
  case "project":
423
80
  if (secondary === "create") {
424
- runProjectCreate(rest);
81
+ runProjectCreate(rest, getStartDir());
425
82
  return;
426
83
  }
427
84
  break;
428
85
  case "tasks":
429
86
  if (secondary === "list") {
430
- runTasksList(rest);
87
+ runTasksList(resolveSchubDir(), rest);
431
88
  return;
432
89
  }
433
90
  if (secondary === "create") {
434
- runTasksCreate(rest);
91
+ runTasksCreate(rest, getStartDir());
92
+ return;
93
+ }
94
+ break;
95
+ case "review":
96
+ if (secondary === "create") {
97
+ runReviewCreate(rest, getStartDir());
98
+ return;
99
+ }
100
+ if (secondary === "complete") {
101
+ runReviewComplete(rest, getStartDir());
102
+ return;
103
+ }
104
+ break;
105
+ case "adr":
106
+ if (secondary === "create") {
107
+ runAdrCreate(rest, getStartDir());
108
+ return;
109
+ }
110
+ break;
111
+ case "cookbook":
112
+ if (secondary === "create") {
113
+ runCookbookCreate(rest, getStartDir());
435
114
  return;
436
115
  }
437
116
  break;
438
- case "lint":
439
- runBiome("lint");
117
+ case "eject":
118
+ runEject(args.slice(1), getStartDir());
440
119
  return;
441
- case "format":
442
- runBiome("format");
120
+ case "init":
121
+ await runInit(args.slice(1), getStartDir());
443
122
  return;
444
123
  case "ui":
445
124
  runUi();
@@ -449,10 +128,8 @@ const runCommand = (): void => {
449
128
  printHelp(1);
450
129
  };
451
130
 
452
- try {
453
- runCommand();
454
- } catch (error) {
131
+ runCommand().catch((error) => {
455
132
  const message = error instanceof Error ? error.message : String(error);
456
133
  process.stderr.write(`${message}\n`);
457
134
  process.exitCode = 1;
458
- }
135
+ });
@@ -0,0 +1,43 @@
1
+ import { expect, test } from "bun:test";
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, mkdirSync, mkdtempSync, realpathSync, statSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { initSchubRoot } from "./init";
7
+
8
+ const createRepoFixture = () => {
9
+ const base = mkdtempSync(join(tmpdir(), "schub-init-root-"));
10
+ const repoRoot = join(base, "repo");
11
+ const startDir = join(repoRoot, "nested", "dir");
12
+ mkdirSync(startDir, { recursive: true });
13
+ return { base, repoRoot, startDir };
14
+ };
15
+
16
+ test("initSchubRoot resolves git worktree root and creates .schub", () => {
17
+ const { repoRoot, startDir } = createRepoFixture();
18
+ const result = spawnSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
19
+
20
+ expect(result.status).toBe(0);
21
+
22
+ const schubRoot = initSchubRoot(startDir);
23
+
24
+ expect(realpathSync(schubRoot)).toBe(realpathSync(join(repoRoot, ".schub")));
25
+ expect(existsSync(schubRoot)).toBe(true);
26
+ expect(statSync(schubRoot).isDirectory()).toBe(true);
27
+ });
28
+
29
+ test("initSchubRoot falls back to startDir when git is unavailable", () => {
30
+ const { startDir } = createRepoFixture();
31
+ const originalPath = process.env.PATH;
32
+ process.env.PATH = "";
33
+
34
+ try {
35
+ const schubRoot = initSchubRoot(startDir);
36
+
37
+ expect(schubRoot).toBe(join(startDir, ".schub"));
38
+ expect(existsSync(schubRoot)).toBe(true);
39
+ expect(statSync(schubRoot).isDirectory()).toBe(true);
40
+ } finally {
41
+ process.env.PATH = originalPath;
42
+ }
43
+ });
package/src/init.ts ADDED
@@ -0,0 +1,27 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { mkdirSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+
5
+ export const resolveGitRoot = (startDir: string) => {
6
+ const result = spawnSync("git", ["rev-parse", "--show-toplevel"], {
7
+ cwd: startDir,
8
+ encoding: "utf8",
9
+ stdio: ["ignore", "pipe", "ignore"],
10
+ });
11
+
12
+ if (result.status !== 0) {
13
+ return null;
14
+ }
15
+
16
+ const output = result.stdout?.trim();
17
+ return output ? resolve(output) : null;
18
+ };
19
+
20
+ export const initSchubRoot = (startDir: string = process.cwd()) => {
21
+ const resolvedStart = resolve(startDir);
22
+ const gitRoot = resolveGitRoot(resolvedStart);
23
+ const root = gitRoot ?? resolvedStart;
24
+ const schubRoot = join(root, ".schub");
25
+ mkdirSync(schubRoot, { recursive: true });
26
+ return schubRoot;
27
+ };
package/src/project.ts CHANGED
@@ -1,6 +1,8 @@
1
- import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { basename, dirname, join, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { resolveSchubRoot } from "./schub-root";
5
+ import { resolveTemplatePath } from "./templates";
4
6
 
5
7
  type ProjectCreateOptions = {
6
8
  repoRoot?: string;
@@ -16,36 +18,6 @@ const TEMPLATE_FILES = {
16
18
 
17
19
  const TEMPLATES_ROOT = fileURLToPath(new URL("../templates/setup-project", import.meta.url));
18
20
 
19
- const isDirectory = (path: string) => {
20
- try {
21
- return statSync(path).isDirectory();
22
- } catch {
23
- return false;
24
- }
25
- };
26
-
27
- const resolveSchubRoot = (startDir: string) => {
28
- let current = resolve(startDir);
29
- const fallback = join(current, ".schub");
30
-
31
- while (true) {
32
- if (basename(current) === ".schub" && isDirectory(current)) {
33
- return current;
34
- }
35
-
36
- const candidate = join(current, ".schub");
37
- if (isDirectory(candidate)) {
38
- return candidate;
39
- }
40
-
41
- const parent = dirname(current);
42
- if (parent === current) {
43
- return fallback;
44
- }
45
- current = parent;
46
- }
47
- };
48
-
49
21
  const readTemplate = (path: string) => {
50
22
  try {
51
23
  return readFileSync(path, "utf8");
@@ -84,7 +56,8 @@ export const createProject = (startDir: string, options: ProjectCreateOptions) =
84
56
  const created: string[] = [];
85
57
 
86
58
  for (const [outputName, templateName] of Object.entries(TEMPLATE_FILES)) {
87
- const templatePath = join(TEMPLATES_ROOT, templateName);
59
+ const bundledPath = join(TEMPLATES_ROOT, templateName);
60
+ const templatePath = resolveTemplatePath(schubRoot, join("setup-project", templateName), bundledPath);
88
61
  const template = readTemplate(templatePath);
89
62
  const rendered = template.split("[Project Name]").join(projectName);
90
63
  const outputPath = join(schubRoot, outputName);
@@ -0,0 +1,33 @@
1
+ import { statSync } from "node:fs";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+
4
+ const isDirectory = (path: string) => {
5
+ try {
6
+ return statSync(path).isDirectory();
7
+ } catch {
8
+ return false;
9
+ }
10
+ };
11
+
12
+ export const resolveSchubRoot = (startDir: string = process.cwd()) => {
13
+ const start = resolve(startDir);
14
+ const fallback = join(start, ".schub");
15
+ let current = start;
16
+
17
+ while (true) {
18
+ if (basename(current) === ".schub" && isDirectory(current)) {
19
+ return current;
20
+ }
21
+
22
+ const candidate = join(current, ".schub");
23
+ if (isDirectory(candidate)) {
24
+ return candidate;
25
+ }
26
+
27
+ const parent = dirname(current);
28
+ if (parent === current) {
29
+ return fallback;
30
+ }
31
+ current = parent;
32
+ }
33
+ };
@@ -0,0 +1,18 @@
1
+ import { statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ const isFile = (path: string) => {
5
+ try {
6
+ return statSync(path).isFile();
7
+ } catch {
8
+ return false;
9
+ }
10
+ };
11
+
12
+ export const resolveTemplatePath = (schubDir: string, templatePath: string, bundledPath: string) => {
13
+ const localPath = join(schubDir, "templates", templatePath);
14
+ if (isFile(localPath)) {
15
+ return localPath;
16
+ }
17
+ return bundledPath;
18
+ };