create-obsidian-arrow 0.5.1 → 0.5.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 (61) hide show
  1. package/README.md +7 -7
  2. package/cli/create.mjs +65 -0
  3. package/cli/detect-pm.mjs +20 -0
  4. package/cli/lib.mjs +117 -0
  5. package/cli/refresh.mjs +65 -0
  6. package/index.mjs +47 -204
  7. package/package.json +11 -2
  8. package/template/.husky/pre-commit +3 -2
  9. package/template/AGENTS.md +57 -12
  10. package/template/README.md +66 -31
  11. package/template/_gitignore +4 -1
  12. package/template/biome.json +7 -1
  13. package/template/docs/prompts/agent-setup.md +22 -20
  14. package/template/docs/prompts/update-existing.md +3 -3
  15. package/template/docs/workflow.md +11 -7
  16. package/template/package.json +15 -14
  17. package/template/src/components/DiffViewer/DiffViewer.css +41 -0
  18. package/template/src/components/DiffViewer/DiffViewer.ts +55 -0
  19. package/template/src/components/EmptyState/EmptyState.css +5 -5
  20. package/template/src/components/EmptyState/EmptyState.ts +5 -9
  21. package/template/src/utilities.css +158 -0
  22. package/template/src/views/DiffViewer/DiffViewerView.css +42 -0
  23. package/template/src/views/DiffViewer/DiffViewerView.ts +53 -0
  24. package/template/src/views/ExampleView/ExampleView.ts +92 -0
  25. package/template/stories/components/ComponentShell.stories.ts +28 -0
  26. package/template/stories/components/EmptyState.stories.ts +1 -0
  27. package/template/stories/components/LoadingState.stories.ts +1 -0
  28. package/template/stories/components/Toggle.stories.ts +50 -0
  29. package/template/stories/views/DiffViewer/DiffViewer.stories.ts +94 -0
  30. package/template/stories/views/EditorView.stories.ts +55 -0
  31. package/template/stories/views/ExampleView/ExampleView.stories.ts +15 -0
  32. package/template/stories/views/PanelView.stories.ts +61 -0
  33. package/template/stories/views/SettingsPanel/SettingsPanel.stories.ts +14 -0
  34. package/template/test/css-structure.test.mjs +112 -0
  35. package/template/test/template-footguns.test.mjs +85 -6
  36. package/template/test/viewer-stories.test.mjs +12 -0
  37. package/template/tools/router/client.ts +26 -4
  38. package/template/tools/router/routeToPage.ts +29 -13
  39. package/template/tools/sandbox/frame.ts +7 -27
  40. package/template/tools/sandbox/home.ts +6 -11
  41. package/template/tools/sandbox/layout.ts +24 -2
  42. package/template/tools/sandbox/sandbox.css +188 -226
  43. package/template/tools/sandbox/shell.ts +2 -2
  44. package/template/tools/sandbox/toolbar.ts +20 -9
  45. package/template/tools/viewer/ClassesPage.ts +7 -7
  46. package/template/tools/viewer/ComponentsIndex.ts +3 -3
  47. package/template/tools/viewer/StoryPage.ts +53 -40
  48. package/template/tools/viewer/TokensPage.ts +10 -10
  49. package/template/tools/viewer/ViewsIndex.ts +66 -0
  50. package/template/tools/viewer/discovery.ts +2 -0
  51. package/template/tools/viewer/obsidian-classes.ts +1 -1
  52. package/template/tools/viewer/sidebar.ts +27 -38
  53. package/template/tools/viewer/stories.ts +16 -2
  54. package/template/.github/workflows/ci.yml +0 -36
  55. package/template/pnpm-lock.yaml +0 -1608
  56. package/template/scripts/create-component.mjs +0 -101
  57. package/template/scripts/create-view.mjs +0 -75
  58. package/template/src/components/DiffViewer.ts +0 -42
  59. package/template/stories/DiffViewer.stories.ts +0 -75
  60. package/template/stories/SettingsPanel.stories.ts +0 -11
  61. package/template/stories/Toggle.stories.ts +0 -28
package/README.md CHANGED
@@ -42,9 +42,9 @@ scaffold folder (the CLI is cwd-relative); use `--project-dir=<outer-repo>` (or
42
42
  `--global`) to install where an agent at the outer repo looks. The scaffolder
43
43
  prints this hint when it detects nesting.
44
44
 
45
- ## Update an existing project
45
+ ## Refresh an existing project
46
46
 
47
- The scaffolder is create-only, but `update` refreshes an existing project's
47
+ The scaffolder is create-first, but `refresh` refreshes an existing project's
48
48
  **managed** files (`scripts/`, `docs/`, `.github/`, `.husky/`, `biome.json`,
49
49
  agent guides, `tools/viewer/`, `tools/router/`, `tools/sandbox/`, `src/main.ts`,
50
50
  `src/utilities.css`, `test/`) and merges new `package.json` scripts/deps —
@@ -52,19 +52,19 @@ it never touches `src/components/`, `stories/`, `public/`, `index.html`, or
52
52
  build configs:
53
53
 
54
54
  ```sh
55
- npx create-obsidian-arrow update # in the project (or: update <dir>)
56
- npx create-obsidian-arrow update --dry-run # preview
55
+ npx create-obsidian-arrow refresh # in the project (or: refresh <dir>)
56
+ npx create-obsidian-arrow refresh --dry-run # preview
57
57
  ```
58
58
 
59
- Then `pnpm install && pnpm check`.
59
+ Then install with your package manager and run the `check` script.
60
60
 
61
61
  ## Local dev of the initializer
62
62
 
63
63
  From the sandbox repo, before publishing:
64
64
 
65
65
  ```sh
66
- node create-obsidian-arrow/index.mjs ../my-app # scaffold
67
- node create-obsidian-arrow/index.mjs update ../my-app # update
66
+ node create-obsidian-arrow/index.mjs create ../my-app # scaffold
67
+ node create-obsidian-arrow/index.mjs refresh ../my-app # refresh
68
68
  ```
69
69
 
70
70
  ## What you get
package/cli/create.mjs ADDED
@@ -0,0 +1,65 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import chalk from "chalk";
5
+ import { copyTree, ensureTemplate, fail, outerRepoAbove, templateDir } from "./lib.mjs";
6
+
7
+ /**
8
+ * Scaffold a new project into `targetArg`.
9
+ *
10
+ * Copies the vendored template/, restores .gitignore (vendored as _gitignore),
11
+ * names the project after the target dir, and runs `git init`.
12
+ */
13
+ export function create(targetArg, opts = {}) {
14
+ ensureTemplate();
15
+ const destRoot = path.resolve(process.cwd(), targetArg);
16
+ const appName = path.basename(destRoot);
17
+
18
+ if (fs.existsSync(destRoot) && fs.readdirSync(destRoot).length > 0) {
19
+ fail(`target "${targetArg}" already exists and is not empty (use \`refresh\` to refresh it).`);
20
+ }
21
+
22
+ const written = [];
23
+ copyTree(templateDir, destRoot, destRoot, written, false);
24
+ // Restore .gitignore (npm omits .gitignore from packages; vendored as _gitignore).
25
+ const vendoredIgnore = path.join(destRoot, "_gitignore");
26
+ if (fs.existsSync(vendoredIgnore)) {
27
+ fs.renameSync(vendoredIgnore, path.join(destRoot, ".gitignore"));
28
+ }
29
+
30
+ // Personalize the project name via targeted replace (keeps Biome formatting).
31
+ const pkgPath = path.join(destRoot, "package.json");
32
+ const renamed = fs
33
+ .readFileSync(pkgPath, "utf8")
34
+ .replace(/("name":\s*)"[^"]*"/, (_m, prefix) => `${prefix}${JSON.stringify(appName)}`);
35
+ fs.writeFileSync(pkgPath, renamed);
36
+
37
+ spawnSync("git", ["init", "-q"], { cwd: destRoot, stdio: "ignore" });
38
+
39
+ if (opts.json) {
40
+ process.stdout.write(
41
+ `${JSON.stringify({ action: "create", dir: destRoot, written, pkgChanges: [`name: ${appName}`] })}\n`
42
+ );
43
+ return;
44
+ }
45
+
46
+ console.log(
47
+ `\n${chalk.green("Scaffolded")} ${chalk.bold(appName)} in ${path.relative(process.cwd(), destRoot) || "."}\n`
48
+ );
49
+ // A fresh scaffold has no lockfile yet — stay package-manager-neutral.
50
+ console.log("Next steps:");
51
+ console.log(chalk.cyan(` cd ${targetArg}`));
52
+ console.log(` ${chalk.dim("# install with your package manager (pnpm / npm / bun / yarn)")}`);
53
+ console.log(chalk.cyan(" <pm> install"));
54
+ console.log(
55
+ ` ${chalk.cyan("<pm> run pull-css")} ${chalk.dim("# extract Obsidian's app.css (macOS auto-detect)")}`
56
+ );
57
+ console.log(chalk.cyan(" <pm> run dev\n"));
58
+
59
+ const outer = outerRepoAbove(destRoot);
60
+ if (outer) {
61
+ console.log(chalk.yellow(`Note: this project is nested inside the repo at ${outer}.`));
62
+ console.log(" Bundled skills install scoped to THIS project. To install them at the");
63
+ console.log(` outer repo instead: <pm> run skills:install -- --yes --project-dir=${outer}\n`);
64
+ }
65
+ }
@@ -0,0 +1,20 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const LOCK = [
5
+ ["bun.lockb", "bun"],
6
+ ["pnpm-lock.yaml", "pnpm"],
7
+ ["yarn.lock", "yarn"],
8
+ ["package-lock.json", "npm"],
9
+ ];
10
+
11
+ /** Detect the package manager governing `dir` from its lockfile.
12
+ * Falls back to npm. `exec` is the argv prefix for running a script. */
13
+ export function detectPM(dir = process.cwd()) {
14
+ for (const [f, name] of LOCK) {
15
+ if (fs.existsSync(path.join(dir, f))) {
16
+ return { name, exec: name === "yarn" ? ["yarn"] : [name, "run"] };
17
+ }
18
+ }
19
+ return { name: "npm", exec: ["npm", "run"] };
20
+ }
package/cli/lib.mjs ADDED
@@ -0,0 +1,117 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const here = path.dirname(fileURLToPath(import.meta.url)); // cli/
6
+ export const pkgRoot = path.resolve(here, ".."); // packages/create-obsidian-arrow
7
+ export const templateDir = path.join(pkgRoot, "template");
8
+
9
+ // Files/dirs the template owns and `refresh` may overwrite/merge.
10
+ //
11
+ // src/components/ and stories/ are user-owned and never touched.
12
+ // tools/ contains all viewer/router/sandbox infrastructure and IS managed.
13
+ // Everything else (public/, index.html, vite.config.ts, tsconfig.json,
14
+ // lockfile, .gitignore, port-parity.json, …) is also user-owned.
15
+ export const MANAGED = [
16
+ // Tooling
17
+ "scripts",
18
+ "docs",
19
+ ".github",
20
+ ".husky",
21
+ "biome.json",
22
+ "AGENTS.md",
23
+ "CLAUDE.md",
24
+ // Viewer infrastructure — all managed; src/components/ and stories/ are user-owned
25
+ "tools",
26
+ "src/main.ts",
27
+ "src/utilities.css",
28
+ // Viewer tests
29
+ "test",
30
+ ];
31
+
32
+ export function fail(message) {
33
+ console.error(`create-obsidian-arrow: ${message}`);
34
+ process.exit(1);
35
+ }
36
+
37
+ export function ensureTemplate() {
38
+ if (!fs.existsSync(templateDir)) {
39
+ fail("template/ is missing — run scripts/sync-template.mjs to build it.");
40
+ }
41
+ }
42
+
43
+ /** Nearest ancestor *above* `dir` that is a git repo, or null. */
44
+ export function outerRepoAbove(dir) {
45
+ let current = path.dirname(path.resolve(dir));
46
+ const fsRoot = path.parse(current).root;
47
+ while (current && current !== fsRoot) {
48
+ if (fs.existsSync(path.join(current, ".git"))) {
49
+ return current;
50
+ }
51
+ current = path.dirname(current);
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /** Recursively copy src→dest; returns the list of written file paths (relative
57
+ * to `base`). Honors `dryRun` (records but doesn't write). */
58
+ export function copyTree(src, dest, base, written, dryRun) {
59
+ const stat = fs.statSync(src);
60
+ if (stat.isDirectory()) {
61
+ if (!dryRun) {
62
+ fs.mkdirSync(dest, { recursive: true });
63
+ }
64
+ for (const entry of fs.readdirSync(src)) {
65
+ copyTree(path.join(src, entry), path.join(dest, entry), base, written, dryRun);
66
+ }
67
+ return;
68
+ }
69
+ written.push(path.relative(base, dest));
70
+ if (!dryRun) {
71
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
72
+ fs.copyFileSync(src, dest);
73
+ }
74
+ }
75
+
76
+ /** Merge template package.json scripts + missing deps into the target's,
77
+ * preserving name/version/identity and existing dep versions. Returns the
78
+ * list of change descriptions.
79
+ *
80
+ * Also propagates top-level `lint-staged` and `pnpm` fields when absent in
81
+ * the target so refreshed old projects get husky/lint-staged fully wired. */
82
+ export function mergePackageJson(targetPkgPath, dryRun) {
83
+ const tpl = JSON.parse(fs.readFileSync(path.join(templateDir, "package.json"), "utf8"));
84
+ const pkg = JSON.parse(fs.readFileSync(targetPkgPath, "utf8"));
85
+ const changes = [];
86
+
87
+ pkg.scripts ??= {};
88
+ for (const [name, cmd] of Object.entries(tpl.scripts ?? {})) {
89
+ if (pkg.scripts[name] !== cmd) {
90
+ changes.push(`script ${name}`);
91
+ pkg.scripts[name] = cmd;
92
+ }
93
+ }
94
+ for (const field of ["dependencies", "devDependencies"]) {
95
+ pkg[field] ??= {};
96
+ for (const [name, version] of Object.entries(tpl[field] ?? {})) {
97
+ if (!(name in pkg[field])) {
98
+ changes.push(`${field}: ${name}`);
99
+ pkg[field][name] = version;
100
+ }
101
+ }
102
+ }
103
+
104
+ // Propagate top-level lint-staged and pnpm fields when absent (so refreshed
105
+ // old projects get husky/lint-staged fully configured, not just the devDeps).
106
+ for (const field of ["lint-staged", "pnpm"]) {
107
+ if (tpl[field] !== undefined && pkg[field] === undefined) {
108
+ changes.push(field);
109
+ pkg[field] = tpl[field];
110
+ }
111
+ }
112
+
113
+ if (changes.length > 0 && !dryRun) {
114
+ fs.writeFileSync(targetPkgPath, `${JSON.stringify(pkg, null, "\t")}\n`);
115
+ }
116
+ return changes;
117
+ }
@@ -0,0 +1,65 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import chalk from "chalk";
4
+ import { detectPM } from "./detect-pm.mjs";
5
+ import { MANAGED, copyTree, ensureTemplate, fail, mergePackageJson, templateDir } from "./lib.mjs";
6
+
7
+ /**
8
+ * Refresh an existing project's *managed* files from the template and merge
9
+ * package.json scripts + missing deps. Preserves user code. `--dry-run` first.
10
+ */
11
+ export function refresh(targetArg, opts = {}) {
12
+ ensureTemplate();
13
+ const dryRun = Boolean(opts.dryRun);
14
+ const root = path.resolve(process.cwd(), targetArg ?? ".");
15
+ if (!fs.existsSync(path.join(root, "package.json"))) {
16
+ fail(`no package.json in ${root} — is this a scaffolded project?`);
17
+ }
18
+
19
+ const written = [];
20
+ for (const name of MANAGED) {
21
+ const src = path.join(templateDir, name);
22
+ if (fs.existsSync(src)) {
23
+ copyTree(src, path.join(root, name), root, written, dryRun);
24
+ }
25
+ }
26
+ const pkgChanges = mergePackageJson(path.join(root, "package.json"), dryRun);
27
+
28
+ // Create stories/ if missing — user-owned (never overwritten), but must
29
+ // exist before the first story file can be added.
30
+ const storiesDir = path.join(root, "stories");
31
+ const storiesCreated = !fs.existsSync(storiesDir);
32
+ if (storiesCreated && !dryRun) {
33
+ fs.mkdirSync(storiesDir);
34
+ }
35
+
36
+ if (opts.json) {
37
+ process.stdout.write(
38
+ `${JSON.stringify({ action: "refresh", dir: root, written, pkgChanges })}\n`
39
+ );
40
+ return;
41
+ }
42
+
43
+ const pm = detectPM(root);
44
+ const run = pm.exec.join(" ");
45
+ const verb = dryRun ? "Would refresh" : "Refreshed";
46
+ console.log(
47
+ `${chalk.green(verb)} ${written.length} managed file(s) in ${path.relative(process.cwd(), root) || "."}:`
48
+ );
49
+ for (const file of written) {
50
+ console.log(` ${file}`);
51
+ }
52
+ if (storiesCreated) {
53
+ console.log(
54
+ ` stories/ ${dryRun ? "(would create empty)" : "(created empty — add your *.stories.ts files here)"}`
55
+ );
56
+ }
57
+ if (pkgChanges.length > 0) {
58
+ console.log(`package.json: ${dryRun ? "would update" : "updated"} ${pkgChanges.join(", ")}`);
59
+ }
60
+ console.log(
61
+ dryRun
62
+ ? "\n(dry run — nothing written.)"
63
+ : `\nLeft alone: src/components/, stories/, public/, index.html, vite.config.ts, tsconfig.json, .gitignore.\n(tools/, src/main.ts, src/utilities.css, and test/ were refreshed above.)\nRun \`${pm.name} install\` then \`${run} check\`. Update skills separately with \`${run} skills:update\`.\n`
64
+ );
65
+ }
package/index.mjs CHANGED
@@ -1,221 +1,64 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * create-obsidian-arrow — scaffold or update an Obsidian-styled Arrow.js sandbox.
3
+ * create-obsidian-arrow — scaffold or refresh an Obsidian-styled Arrow.js sandbox.
4
4
  *
5
- * create-obsidian-arrow <dir> scaffold a new project into <dir>
6
- * create-obsidian-arrow update [dir] refresh an existing project's tooling
7
- * (default dir: cwd) — preserves your code
5
+ * create-obsidian-arrow create <dir> scaffold a new project into <dir>
6
+ * create-obsidian-arrow refresh [dir] refresh an existing project's tooling
7
+ * (default dir: cwd) — preserves your code
8
+ * create-obsidian-arrow <dir> bare-arg shorthand for `create <dir>`
8
9
  *
9
10
  * pnpm create obsidian-arrow my-app
10
- * node create-obsidian-arrow/index.mjs my-app # locally
11
- * node create-obsidian-arrow/index.mjs update ./my-app
11
+ * npx create-obsidian-arrow my-app
12
12
  *
13
- * Scaffold copies the vendored template/, restores .gitignore (vendored as
13
+ * `create` copies the vendored template/, restores .gitignore (vendored as
14
14
  * _gitignore), names the project, and runs `git init`.
15
15
  *
16
- * Update refreshes *managed* files from the template and merges package.json
16
+ * `refresh` refreshes *managed* files from the template and merges package.json
17
17
  * scripts + missing deps. Managed = scripts/, docs/, .github/, .husky/,
18
18
  * biome.json, AGENTS.md, CLAUDE.md, tools/, src/main.ts, src/utilities.css,
19
19
  * test/. Never touches src/components/, stories/, public/, index.html,
20
20
  * vite.config.ts, tsconfig.json, or .gitignore. Use --dry-run first.
21
21
  */
22
- import { spawnSync } from "node:child_process";
23
- import fs from "node:fs";
24
- import path from "node:path";
25
- import { fileURLToPath } from "node:url";
26
-
27
- const here = path.dirname(fileURLToPath(import.meta.url));
28
- const templateDir = path.join(here, "template");
29
-
30
- // Files/dirs the template owns and `update` may overwrite/merge.
31
- //
32
- // src/components/ and stories/ are user-owned and never touched.
33
- // tools/ contains all viewer/router/sandbox infrastructure and IS managed.
34
- // Everything else (public/, index.html, vite.config.ts, tsconfig.json,
35
- // lockfile, .gitignore, port-parity.json, …) is also user-owned. Skills aren't here — they're pulled from the published
36
- // repo via `pnpm skills:update`, not vendored into the scaffold.
37
- const MANAGED = [
38
- // Tooling
39
- "scripts",
40
- "docs",
41
- ".github",
42
- ".husky",
43
- "biome.json",
44
- "AGENTS.md",
45
- "CLAUDE.md",
46
- // Viewer infrastructure — all managed; src/components/ and stories/ are user-owned
47
- "tools",
48
- "src/main.ts",
49
- "src/utilities.css",
50
- // Viewer tests
51
- "test",
52
- ];
53
-
22
+ import { Command } from "commander";
23
+ import { create } from "./cli/create.mjs";
24
+ import { refresh } from "./cli/refresh.mjs";
25
+
26
+ const program = new Command();
27
+
28
+ program
29
+ .name("create-obsidian-arrow")
30
+ .description("Scaffold or refresh an Obsidian-styled Arrow.js UI sandbox.");
31
+
32
+ program
33
+ .command("create")
34
+ .argument("<dir>", "directory to scaffold into")
35
+ .description("scaffold a new project into <dir>")
36
+ .option("--json", "emit a single JSON line describing the result")
37
+ .allowExcessArguments(false)
38
+ .action((dir, opts) => create(dir, opts));
39
+
40
+ program
41
+ .command("refresh")
42
+ .argument("[dir]", "project directory (default: cwd)")
43
+ .description("refresh an existing project's managed tooling")
44
+ .option("--dry-run", "list what would change without writing")
45
+ .option("--json", "emit a single JSON line describing the result")
46
+ .allowExcessArguments(false)
47
+ .action((dir, opts) => refresh(dir, opts));
48
+
49
+ // Bare-arg shorthand: `create-obsidian-arrow my-app` → `create my-app`, so
50
+ // `npx create-obsidian-arrow my-app` still works. If the first non-option token
51
+ // isn't a known subcommand, prepend `create` at the FRONT so any leading global
52
+ // flags (e.g. `--json my-app`) attach to the `create` subcommand rather than the
53
+ // (option-less) root program.
54
54
  const argv = process.argv.slice(2);
55
- const dryRun = argv.includes("--dry-run");
56
-
57
- function fail(message) {
58
- console.error(`create-obsidian-arrow: ${message}`);
59
- process.exit(1);
60
- }
61
-
62
- if (!fs.existsSync(templateDir)) {
63
- fail("template/ is missing — run scripts/sync-template.mjs to build it.");
64
- }
65
-
66
- /** Nearest ancestor *above* `dir` that is a git repo, or null. */
67
- function outerRepoAbove(dir) {
68
- let current = path.dirname(path.resolve(dir));
69
- const fsRoot = path.parse(current).root;
70
- while (current && current !== fsRoot) {
71
- if (fs.existsSync(path.join(current, ".git"))) {
72
- return current;
73
- }
74
- current = path.dirname(current);
75
- }
76
- return null;
55
+ const firstToken = argv.find((a) => !a.startsWith("-"));
56
+ const knownCommands = new Set(["create", "refresh", "help"]);
57
+ if (firstToken && !knownCommands.has(firstToken)) {
58
+ argv.unshift("create");
77
59
  }
78
-
79
- /** Recursively copy src→dest; returns the list of written file paths (relative
80
- * to `base`). Honors --dry-run (records but doesn't write). */
81
- function copyTree(src, dest, base, written) {
82
- const stat = fs.statSync(src);
83
- if (stat.isDirectory()) {
84
- if (!dryRun) {
85
- fs.mkdirSync(dest, { recursive: true });
86
- }
87
- for (const entry of fs.readdirSync(src)) {
88
- copyTree(path.join(src, entry), path.join(dest, entry), base, written);
89
- }
90
- return;
91
- }
92
- written.push(path.relative(base, dest));
93
- if (!dryRun) {
94
- fs.mkdirSync(path.dirname(dest), { recursive: true });
95
- fs.copyFileSync(src, dest);
96
- }
60
+ if (argv.length === 0) {
61
+ program.help();
97
62
  }
98
63
 
99
- function scaffold(targetArg) {
100
- const destRoot = path.resolve(process.cwd(), targetArg);
101
- const appName = path.basename(destRoot);
102
-
103
- if (fs.existsSync(destRoot) && fs.readdirSync(destRoot).length > 0) {
104
- fail(`target "${targetArg}" already exists and is not empty (use \`update\` to refresh it).`);
105
- }
106
-
107
- const written = [];
108
- copyTree(templateDir, destRoot, destRoot, written);
109
- // Restore .gitignore (npm omits .gitignore from packages; vendored as _gitignore).
110
- const vendoredIgnore = path.join(destRoot, "_gitignore");
111
- if (fs.existsSync(vendoredIgnore)) {
112
- fs.renameSync(vendoredIgnore, path.join(destRoot, ".gitignore"));
113
- }
114
-
115
- // Personalize the project name via targeted replace (keeps Biome formatting).
116
- const pkgPath = path.join(destRoot, "package.json");
117
- const renamed = fs
118
- .readFileSync(pkgPath, "utf8")
119
- .replace(/("name":\s*)"[^"]*"/, (_m, prefix) => `${prefix}${JSON.stringify(appName)}`);
120
- fs.writeFileSync(pkgPath, renamed);
121
-
122
- spawnSync("git", ["init", "-q"], { cwd: destRoot, stdio: "ignore" });
123
-
124
- console.log(`\nScaffolded ${appName} in ${path.relative(process.cwd(), destRoot) || "."}\n`);
125
- console.log("Next steps:");
126
- console.log(` cd ${targetArg}`);
127
- console.log(" pnpm install");
128
- console.log(" pnpm pull-css # extract Obsidian's app.css (macOS auto-detect)");
129
- console.log(" pnpm dev\n");
130
-
131
- const outer = outerRepoAbove(destRoot);
132
- if (outer) {
133
- console.log(`Note: this project is nested inside the repo at ${outer}.`);
134
- console.log(" Bundled skills install scoped to THIS project. To install them at the");
135
- console.log(` outer repo instead: pnpm skills:install --yes --project-dir=${outer}\n`);
136
- }
137
- }
138
-
139
- /** Merge template package.json scripts + missing deps into the target's,
140
- * preserving name/version/identity and existing dep versions. */
141
- function mergePackageJson(targetPkgPath) {
142
- const tpl = JSON.parse(fs.readFileSync(path.join(templateDir, "package.json"), "utf8"));
143
- const pkg = JSON.parse(fs.readFileSync(targetPkgPath, "utf8"));
144
- const changes = [];
145
-
146
- pkg.scripts ??= {};
147
- for (const [name, cmd] of Object.entries(tpl.scripts ?? {})) {
148
- if (pkg.scripts[name] !== cmd) {
149
- changes.push(`script ${name}`);
150
- pkg.scripts[name] = cmd;
151
- }
152
- }
153
- for (const field of ["dependencies", "devDependencies"]) {
154
- pkg[field] ??= {};
155
- for (const [name, version] of Object.entries(tpl[field] ?? {})) {
156
- if (!(name in pkg[field])) {
157
- changes.push(`${field}: ${name}`);
158
- pkg[field][name] = version;
159
- }
160
- }
161
- }
162
-
163
- if (changes.length > 0 && !dryRun) {
164
- fs.writeFileSync(targetPkgPath, `${JSON.stringify(pkg, null, "\t")}\n`);
165
- }
166
- return changes;
167
- }
168
-
169
- function update(targetArg) {
170
- const root = path.resolve(process.cwd(), targetArg ?? ".");
171
- if (!fs.existsSync(path.join(root, "package.json"))) {
172
- fail(`no package.json in ${root} — is this a scaffolded project?`);
173
- }
174
-
175
- const written = [];
176
- for (const name of MANAGED) {
177
- const src = path.join(templateDir, name);
178
- if (fs.existsSync(src)) {
179
- copyTree(src, path.join(root, name), root, written);
180
- }
181
- }
182
- const pkgChanges = mergePackageJson(path.join(root, "package.json"));
183
-
184
- // Create stories/ if missing — user-owned (never overwritten), but must
185
- // exist before the first story file can be added.
186
- const storiesDir = path.join(root, "stories");
187
- const storiesCreated = !fs.existsSync(storiesDir);
188
- if (storiesCreated && !dryRun) {
189
- fs.mkdirSync(storiesDir);
190
- }
191
-
192
- const verb = dryRun ? "Would refresh" : "Refreshed";
193
- console.log(
194
- `${verb} ${written.length} managed file(s) in ${path.relative(process.cwd(), root) || "."}:`
195
- );
196
- for (const file of written) {
197
- console.log(` ${file}`);
198
- }
199
- if (storiesCreated) {
200
- console.log(
201
- ` stories/ ${dryRun ? "(would create empty)" : "(created empty — add your *.stories.ts files here)"}`
202
- );
203
- }
204
- if (pkgChanges.length > 0) {
205
- console.log(`package.json: ${dryRun ? "would update" : "updated"} ${pkgChanges.join(", ")}`);
206
- }
207
- console.log(
208
- dryRun
209
- ? "\n(dry run — nothing written.)"
210
- : "\nLeft alone: src/components/, stories/, public/, index.html, vite.config.ts, tsconfig.json, .gitignore.\n(tools/, src/main.ts, src/utilities.css, and test/ were refreshed above.)\nRun `pnpm install` then `pnpm check`. Update skills separately with `pnpm skills:update`.\n"
211
- );
212
- }
213
-
214
- const command = argv[0];
215
- if (command === "update") {
216
- update(argv.find((a) => a !== "update" && !a.startsWith("--")));
217
- } else if (!command || command.startsWith("--")) {
218
- fail("usage: create-obsidian-arrow <directory> | update [directory]");
219
- } else {
220
- scaffold(command);
221
- }
64
+ program.parse(argv, { from: "user" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-obsidian-arrow",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Scaffold an Obsidian-styled Arrow.js UI sandbox (pnpm create obsidian-arrow <dir>).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "index.mjs",
11
+ "cli",
11
12
  "template"
12
13
  ],
13
14
  "engines": {
@@ -36,5 +37,13 @@
36
37
  "url": "git+https://github.com/kylebrodeur/obsidian-arrow-sandbox.git",
37
38
  "directory": "create-obsidian-arrow"
38
39
  },
39
- "scripts": {}
40
+ "dependencies": {
41
+ "chalk": "^5.6.2",
42
+ "commander": "^12.1.0"
43
+ },
44
+ "scripts": {
45
+ "test": "node --test test/**/*.test.mjs",
46
+ "check": "node --test test/**/*.test.mjs",
47
+ "ci": "node --test test/**/*.test.mjs"
48
+ }
40
49
  }
@@ -1,2 +1,3 @@
1
- pnpm exec lint-staged
2
- pnpm typecheck
1
+ #!/usr/bin/env sh
2
+ ./node_modules/.bin/lint-staged
3
+ ./node_modules/.bin/tsc -p tsconfig.json --noEmit