cans-spec 0.1.2 → 0.3.0

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/README.md CHANGED
@@ -169,7 +169,15 @@ This is the most important section.
169
169
 
170
170
  ## Install
171
171
 
172
- Requires [Bun](https://bun.sh) ≥ 1.0 on your PATH (the CLI runs on the Bun runtime). No build step. Source IS distribution.
172
+ No build step. Source IS distribution. Two runtimes are supported — Bun is the
173
+ primary, Node.js is the fallback (issue #12):
174
+
175
+ - **Bun ≥ 1.0 (recommended, primary)** — fastest path, used automatically when
176
+ Bun is on your PATH.
177
+ - **Node.js ≥ 23.2 (fallback)** — for Termux / Android harnesses and any
178
+ environment without Bun. Termux: `pkg install nodejs` (ships Node 26.x ✓).
179
+ Node's builtin TypeScript stripping runs the sources directly; running from
180
+ a source checkout also works on Node ≥ 22.6 via `--experimental-strip-types`.
173
181
 
174
182
  **npm**
175
183
 
@@ -187,6 +195,8 @@ bun install -g cans-spec
187
195
 
188
196
  ```bash
189
197
  npx -p cans-spec cans init
198
+ # or, with Bun installed:
199
+ bunx cans-spec init
190
200
  ```
191
201
 
192
202
  All three give you the same `cans` command:
@@ -315,19 +325,21 @@ cans/
315
325
 
316
326
  ## Requirements
317
327
 
318
- - [Bun](https://bun.sh) ≥ 1.0
328
+ - [Bun](https://bun.sh) ≥ 1.0 (primary) **or** Node.js ≥ 23.2 (fallback, no Bun needed — Termux: `pkg install nodejs`)
319
329
  - Git (for coordination; not required by CANS itself)
320
330
  - An AI agent that can read markdown (all of them)
321
331
 
322
- No Node.js. No Python. No `package-lock.json`. No `node_modules`. The `dependencies` field in `package.json` does not exist.
332
+ No Python. No `package-lock.json`. No build step. The `dependencies` field in `package.json` does not exist — zero runtime dependencies on both runtimes.
323
333
 
324
334
  ---
325
335
 
326
336
  ## Contributing
327
337
 
328
- The entire codebase is ~1080 lines of TypeScript across 20 files. Read it in an afternoon.
338
+ The entire codebase is ~1100 lines of TypeScript across 20 files. Read it in an afternoon.
329
339
 
330
- - `bun test` runs the suite (~95 tests)
340
+ - `bun test` runs the suite on the primary runtime; `npm run test:node` runs the same suite on the Node fallback (node:test via a tiny bun:test compat shim in `test/node-compat.ts`)
341
+ - CI runs both (`test-bun` + `test-node` matrix on Node 22/24)
342
+ - All runtime-sensitive IO goes through one shim, `src/core/runtime.ts` — the only file allowed to touch Bun APIs; a self-audit test keeps it that way
331
343
  - No build step. Edit `src/`, run `bun test`, done.
332
344
  - PRs that add features get closed. PRs that delete code get merged.
333
345
  - If you want a new command, open an issue and justify it. Most get rejected. That's the point.
package/bin/cans.js ADDED
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Portable launcher for the `cans` bin (issue #12).
4
+ *
5
+ * Priority: Bun stays the primary runtime.
6
+ * 1. Already executing under Bun (`bunx cans-spec`, `bun run`) → run the CLI
7
+ * in-process; the runtime shim's Bun fast paths apply.
8
+ * 2. Bun on PATH but this launcher was started by Node (`npm i -g cans-spec`
9
+ * then `cans ...`) → re-exec `bun src/cli.ts` so Bun fast paths apply.
10
+ * 3. No Bun → run the CLI in-process on Node, stripping TypeScript via
11
+ * Node's builtin `stripTypeScriptTypes` through bin/ts-loader.mjs (Node's
12
+ * default stripping refuses files under node_modules — see issue #12 QA).
13
+ *
14
+ * This file is plain JavaScript ON PURPOSE: it must execute on any Node that
15
+ * npm can provide, before any TypeScript capability is available.
16
+ */
17
+ import { spawnSync } from 'node:child_process';
18
+ import { register } from 'node:module';
19
+ import { dirname, join } from 'node:path';
20
+ import { fileURLToPath, pathToFileURL } from 'node:url';
21
+
22
+ const here = dirname(fileURLToPath(import.meta.url));
23
+ const cli = join(here, '..', 'src', 'cli.ts');
24
+ const args = process.argv.slice(2);
25
+
26
+ /** Run cli.ts in the current process; it calls process.exit() itself, but a
27
+ * load-time failure (unreadable package.json, syntax) must still report. */
28
+ async function runInProcess() {
29
+ try {
30
+ await import(pathToFileURL(cli).href);
31
+ process.exit(process.exitCode ?? 0);
32
+ } catch (e) {
33
+ console.error(`✗ Internal error: ${e instanceof Error ? e.message : e}`);
34
+ process.exit(2);
35
+ }
36
+ }
37
+
38
+ // 1. Already under Bun — in-process, Bun fast paths.
39
+ if (typeof globalThis.Bun !== 'undefined') {
40
+ await runInProcess();
41
+ }
42
+
43
+ // 2. Bun on PATH — re-exec so Bun stays the primary runtime.
44
+ const probe = spawnSync('bun', ['--version'], { stdio: 'ignore' });
45
+ if (!probe.error && probe.status === 0) {
46
+ const r = spawnSync('bun', [cli, ...args], { stdio: 'inherit' });
47
+ if (r.error) {
48
+ console.error(`✗ Internal error: failed to launch bun: ${r.error.message}`);
49
+ process.exit(2);
50
+ }
51
+ process.exit(r.status ?? 130);
52
+ }
53
+
54
+ // 3. Node fallback — needs node:module.stripTypeScriptTypes (Node ≥23.2; made
55
+ // stable in 23.6). Termux `pkg install nodejs` ships 26.x, which qualifies.
56
+ // Namespace access (not a named import) so older Nodes link this file fine.
57
+ const { stripTypeScriptTypes } = await import('node:module');
58
+ if (typeof stripTypeScriptTypes !== 'function') {
59
+ console.error(
60
+ `✗ cans requires Bun >= 1.0 (primary) or Node >= 23.2 with builtin TypeScript stripping; found Node ${process.versions.node} without Bun.`,
61
+ );
62
+ console.error(' Install Bun: https://bun.sh — Termux: pkg install nodejs.');
63
+ process.exit(1);
64
+ }
65
+
66
+ // Node prints an ExperimentalWarning for stripTypeScriptTypes on every run;
67
+ // suppress just that one and pass everything else through untouched.
68
+ process.removeAllListeners('warning');
69
+ process.on('warning', (w) => {
70
+ if (w && w.name === 'ExperimentalWarning' && String(w.message).includes('stripTypeScriptTypes')) return;
71
+ console.error(`(node:${process.pid}) [${w?.name ?? 'Warning'}] ${w?.message ?? w}`);
72
+ });
73
+
74
+ // 4. Register the .ts loader hook, then execute the CLI in-process.
75
+ register(new URL('./ts-loader.mjs', import.meta.url));
76
+ await runInProcess();
@@ -0,0 +1,34 @@
1
+ /**
2
+ * ESM loader hook for the Node fallback runtime (issue #12).
3
+ *
4
+ * Node's built-in TypeScript type-stripping refuses to process files under
5
+ * `node_modules`, which breaks `npm i -g cans-spec` on machines without Bun —
6
+ * the exact Termux scenario this package must support. This hook performs the
7
+ * SAME erasable-syntax stripping ourselves via Node's builtin
8
+ * `node:module.stripTypeScriptTypes` (no dependencies, no build step — source
9
+ * stays distribution), bypassing only that path-based veto.
10
+ */
11
+ import { readFile } from 'node:fs/promises';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { stripTypeScriptTypes } from 'node:module';
14
+
15
+ // This hook runs inside Node's loader thread, whose default handler prints the
16
+ // `stripTypeScriptTypes` ExperimentalWarning on every CLI run. Suppress warnings
17
+ // scoped to THIS thread only (CANS_DEBUG_WARNINGS=1 restores them).
18
+ if (process.env.CANS_DEBUG_WARNINGS !== '1') {
19
+ process.removeAllListeners('warning');
20
+ process.on('warning', () => {});
21
+ }
22
+
23
+ export async function load(url, context, nextLoad) {
24
+ if (url.endsWith('.ts')) {
25
+ const file = fileURLToPath(url);
26
+ const code = await readFile(file, 'utf8');
27
+ // Node ≥23.2 returns an object ({ source }); some versions return the
28
+ // stripped string directly — accept both shapes.
29
+ const result = stripTypeScriptTypes(code, { sourceUrl: file });
30
+ const source = typeof result === 'string' ? result : result?.source;
31
+ return { format: 'module', source, shortCircuit: true };
32
+ }
33
+ return nextLoad(url, context);
34
+ }
package/package.json CHANGED
@@ -1,20 +1,23 @@
1
1
  {
2
2
  "name": "cans-spec",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "Canonical Agent-Native Spec — the outline is the spec, the state, and the task board",
5
5
  "bin": {
6
- "cans": "./src/cli.ts"
6
+ "cans": "./bin/cans.js"
7
7
  },
8
8
  "type": "module",
9
9
  "engines": {
10
- "bun": ">=1.0.0"
10
+ "bun": ">=1.0.0",
11
+ "node": ">=23.2.0"
11
12
  },
12
13
  "scripts": {
13
- "typecheck": "bunx tsc --noEmit",
14
+ "typecheck": "tsc --noEmit",
14
15
  "test": "bun test",
15
- "prepublishOnly": "bun run typecheck && bun test"
16
+ "test:node": "node --test --experimental-strip-types \"test/**/*.test.ts\"",
17
+ "prepublishOnly": "npm run typecheck && bun test"
16
18
  },
17
19
  "files": [
20
+ "bin/",
18
21
  "src/",
19
22
  "templates/",
20
23
  "README.md",
package/src/cli.ts CHANGED
@@ -1,24 +1,28 @@
1
- #!/usr/bin/env bun
1
+ #!/usr/bin/env node
2
2
 
3
- import type { CommandResult } from './types';
4
- import { emit } from './core/output';
5
- import pkg from '../package.json';
3
+ import type { CommandResult } from './types.ts';
4
+ import { emit } from './core/output.ts';
5
+ import { argv, readText, dirFromUrl } from './core/runtime.ts';
6
+ import { join } from 'node:path';
6
7
 
7
- /** §44: version line — derived from package.json so it can never drift. */
8
- const VERSION: string = pkg.version;
8
+ /** §44: version line — derived from package.json so it can never drift.
9
+ * Read through the runtime shim (issue #12): a bare JSON import has no
10
+ * import attributes and is therefore not portable to the Node fallback. */
11
+ const PKG_FILE = join(dirFromUrl(import.meta.url), '..', 'package.json');
12
+ const VERSION: string = (JSON.parse(await readText(PKG_FILE)) as { version: string }).version;
9
13
 
10
- const [cmd, ...args] = Bun.argv.slice(2);
14
+ const [cmd, ...args] = argv();
11
15
 
12
16
  async function dispatch(): Promise<CommandResult> {
13
17
  switch (cmd) {
14
- case 'init': return (await import('./commands/init')).run(args);
15
- case 'check': return (await import('./commands/check')).run(args);
16
- case 'new': return (await import('./commands/new')).run(args);
17
- case 'done': return (await import('./commands/done')).run(args);
18
- case 'status': return (await import('./commands/status')).run(args);
19
- case 'budget': return (await import('./commands/budget')).run(args);
20
- case 'import': return (await import('./commands/import')).run(args);
21
- case 'export': return (await import('./commands/export')).run(args);
18
+ case 'init': return (await import('./commands/init.ts')).run(args);
19
+ case 'check': return (await import('./commands/check.ts')).run(args);
20
+ case 'new': return (await import('./commands/new.ts')).run(args);
21
+ case 'done': return (await import('./commands/done.ts')).run(args);
22
+ case 'status': return (await import('./commands/status.ts')).run(args);
23
+ case 'budget': return (await import('./commands/budget.ts')).run(args);
24
+ case 'import': return (await import('./commands/import.ts')).run(args);
25
+ case 'export': return (await import('./commands/export.ts')).run(args);
22
26
  case 'help':
23
27
  case '-h': // §20/§44: conventional help shortcuts
24
28
  case '--help':
@@ -1,10 +1,11 @@
1
1
  import { join, basename } from 'path';
2
- import type { BudgetReadResult, BudgetWriteResult, OutlineNode, Rules } from '../types';
3
- import { resolveWorkspaceRoot, discoverSpecFiles, discoverActiveTasks, dirExists } from '../core/fs';
4
- import { parseOutline } from '../core/outline';
5
- import { loadRules } from '../core/rules';
6
- import { buildRefGraph } from '../core/refs';
7
- import { buildReadPlan, buildWritePlan } from '../core/token-budget';
2
+ import type { BudgetReadResult, BudgetWriteResult, OutlineNode, Rules } from '../types.ts';
3
+ import { readText } from '../core/runtime.ts';
4
+ import { resolveWorkspaceRoot, discoverSpecFiles, discoverActiveTasks, dirExists } from '../core/fs.ts';
5
+ import { parseOutline } from '../core/outline.ts';
6
+ import { loadRules } from '../core/rules.ts';
7
+ import { buildRefGraph } from '../core/refs.ts';
8
+ import { buildReadPlan, buildWritePlan } from '../core/token-budget.ts';
8
9
 
9
10
  export interface BudgetArgs {
10
11
  mode: 'read' | 'write';
@@ -164,7 +165,7 @@ export async function run(args: string[]): Promise<BudgetReadResult | BudgetWrit
164
165
  const files = new Map<string, OutlineNode[]>();
165
166
  for (const rel of discoverSpecFiles(workspace)) {
166
167
  try {
167
- files.set(rel, parseOutline(await Bun.file(join(workspace, rel)).text(), rel));
168
+ files.set(rel, parseOutline(await readText(join(workspace, rel)), rel));
168
169
  } catch {
169
170
  // unreadable spec file: excluded from the plan
170
171
  }
@@ -1,23 +1,24 @@
1
1
  import { join } from 'path';
2
- import type { CheckResult, Issue, OutlineNode } from '../types';
2
+ import type { CheckResult, Issue, OutlineNode } from '../types.ts';
3
+ import { readText, writeText } from '../core/runtime.ts';
3
4
  import {
4
5
  discoverSpecFiles, discoverActiveTasks, discoverAdrs, resolveWorkspaceRoot,
5
6
  dirExists, detectFlatFolderConflicts, detectMalformedSpecDirs, discoverOverflowTargets,
6
- } from '../core/fs';
7
+ } from '../core/fs.ts';
7
8
  import {
8
- parseOutline, extractBackPointers, flattenNodes, maxDepth as outlineMaxDepth,
9
+ parseOutline, extractBackPointers, realNodes, maxDepth as outlineMaxDepth,
9
10
  type ParseWarning,
10
- } from '../core/outline';
11
- import { loadRules } from '../core/rules';
12
- import { checkStructure, checkTbdPolicy } from '../core/structure';
13
- import { checkStyle } from '../core/style';
14
- import { checkOverflow, checkNoChaining } from '../core/overflow';
15
- import { checkRedundancy } from '../core/redundancy';
11
+ } from '../core/outline.ts';
12
+ import { loadRules } from '../core/rules.ts';
13
+ import { checkStructure, checkTbdPolicy } from '../core/structure.ts';
14
+ import { checkStyle } from '../core/style.ts';
15
+ import { checkOverflow, checkNoChaining } from '../core/overflow.ts';
16
+ import { checkRedundancy } from '../core/redundancy.ts';
16
17
  import {
17
18
  buildRefGraph, checkRefs, detectDeepHops, detectOrphans,
18
19
  rebuildBackPointers, targetMatchesKey,
19
- } from '../core/refs';
20
- import { parseArgs, formatArgErrors, type FlagSpec } from '../core/args';
20
+ } from '../core/refs.ts';
21
+ import { parseArgs, formatArgErrors, type FlagSpec } from '../core/args.ts';
21
22
 
22
23
  export interface CheckArgs {
23
24
  fix: boolean;
@@ -41,6 +42,10 @@ const CHECK_FLAGS: FlagSpec[] = [
41
42
  ];
42
43
 
43
44
  const REF_BY_RE = /<!--\s*ref-by:\s*(.*?)\s*-->/;
45
+ // Fence marker rule, mirrored from src/core/outline.ts (issue #6): a line whose
46
+ // trimmed form starts with ``` toggles fence state. Kept as a regex to reuse
47
+ // the outline.ts FENCE_RE convention; the two must never diverge.
48
+ const FENCE_RE = /^```/;
44
49
 
45
50
  // globFiles throws ENOENT on missing dirs — guard the optional ones.
46
51
  function safeActiveTasks(root: string): string[] {
@@ -110,15 +115,56 @@ function refTargetKey(name: string, keys: Iterable<string>): string | null {
110
115
  return null;
111
116
  }
112
117
 
118
+ /** Issue #6: remove ONLY the ref-by comment substring from a non-bullet line,
119
+ * keeping the surrounding prose. Collapses the double space left where the
120
+ * comment sat (one seam space absorbed) and trims trailing whitespace. The
121
+ * caller drops the line only when nothing but the comment remains. */
122
+ function stripRefByKeepProse(raw: string): string {
123
+ const m = raw.match(REF_BY_RE);
124
+ if (m === null || m.index === undefined) return raw;
125
+ let out = raw.slice(0, m.index) + raw.slice(m.index + m[0].length);
126
+ if (out.charAt(m.index - 1) === ' ' && out.charAt(m.index) === ' ') {
127
+ out = out.slice(0, m.index - 1) + out.slice(m.index);
128
+ }
129
+ return out.replace(/[ \t]+$/, '');
130
+ }
131
+
113
132
  /** Rewrite `<!-- ref-by: ... -->` comments in one spec file source.
114
- * Replaces the first existing comment's content, drops duplicates/stale ones,
115
- * or inserts a fresh comment line right after the first root bullet. */
116
- function rewriteRefBy(source: string, body: string | null): string {
133
+ * Fence-aware and prose-preserving (issue #6):
134
+ * - Lines inside ``` fences (and the fence markers themselves) are preserved
135
+ * byte-for-byte: never scanned as hits, never rewritten, never dropped, and
136
+ * fenced "- fake bullets" are never insertion anchors.
137
+ * - Replaces the first existing comment's content; duplicates/stale hits keep
138
+ * their line — bullets are stripped as before, non-bullet prose loses only
139
+ * the comment substring (the line is dropped only when bare).
140
+ * - With no hits and a desired body, inserts a fresh comment line right after
141
+ * the first root bullet outside any fence, appended at end when none exists
142
+ * — unless the file ends inside an unterminated fence, in which case the
143
+ * comment is inserted before the fence opener (never inside a fence).
144
+ * Exported for regression tests (issue #6); behavior lives in this module. */
145
+ export function rewriteRefBy(source: string, body: string | null): string {
117
146
  const lines: Array<string | null> = source.split('\n');
118
147
  const comment = body !== null && body !== '' ? `<!-- ref-by: ${body} -->` : null;
119
148
  const hits: number[] = [];
149
+ let fenceOpen = false;
150
+ let fenceOpener = -1;
151
+ const inFence: boolean[] = new Array(lines.length).fill(false);
120
152
  for (let i = 0; i < lines.length; i++) {
121
- if (lines[i] !== null && REF_BY_RE.test(lines[i]!)) hits.push(i);
153
+ const raw = lines[i]!;
154
+ if (FENCE_RE.test(raw.trim())) {
155
+ // Fence marker: toggles state; never a hit, never an anchor, never touched.
156
+ inFence[i] = true;
157
+ if (fenceOpen) {
158
+ fenceOpen = false;
159
+ } else {
160
+ fenceOpen = true;
161
+ fenceOpener = i;
162
+ }
163
+ continue;
164
+ }
165
+ inFence[i] = fenceOpen;
166
+ if (fenceOpen) continue;
167
+ if (REF_BY_RE.test(raw)) hits.push(i);
122
168
  }
123
169
  if (hits.length > 0) {
124
170
  for (let j = 0; j < hits.length; j++) {
@@ -128,17 +174,27 @@ function rewriteRefBy(source: string, body: string | null): string {
128
174
  lines[i] = raw.replace(REF_BY_RE, comment);
129
175
  } else {
130
176
  const isBullet = /^\s*-\s/.test(raw);
131
- lines[i] = isBullet ? raw.replace(REF_BY_RE, '').replace(/[ \t]+$/, '') : null;
177
+ if (isBullet) {
178
+ lines[i] = raw.replace(REF_BY_RE, '').replace(/[ \t]+$/, '');
179
+ } else {
180
+ // Issue #6: never delete a prose line whole — strip the comment only.
181
+ const stripped = stripRefByKeepProse(raw);
182
+ lines[i] = stripped.trim() === '' ? null : stripped;
183
+ }
132
184
  }
133
185
  }
134
186
  } else if (comment !== null) {
135
187
  let insertAt = lines.length;
136
188
  for (let i = 0; i < lines.length; i++) {
189
+ if (inFence[i]) continue; // fenced `- fake bullets` are not anchors
137
190
  if (/^- /.test(lines[i]!)) {
138
191
  insertAt = i + 1;
139
192
  break;
140
193
  }
141
194
  }
195
+ // Issue #6: never insert inside an open fence — appending at EOF while a
196
+ // fence is unterminated would corrupt the fenced region.
197
+ if (insertAt >= lines.length && fenceOpen) insertAt = fenceOpener;
142
198
  lines.splice(insertAt, 0, comment);
143
199
  }
144
200
  return lines.filter((l): l is string => l !== null).join('\n');
@@ -181,7 +237,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
181
237
  for (const rel of specRel) {
182
238
  let text = '';
183
239
  try {
184
- text = await Bun.file(join(root, rel)).text();
240
+ text = await readText(join(root, rel));
185
241
  } catch (e) {
186
242
  issues.push({
187
243
  file: rel, line: 0, level: 'error', category: 'structure',
@@ -212,7 +268,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
212
268
  const auxFiles = new Map<string, OutlineNode[]>();
213
269
  for (const rel of [...safeActiveTasks(root), ...safeAdrs(root)]) {
214
270
  try {
215
- const text = await Bun.file(join(root, rel)).text();
271
+ const text = await readText(join(root, rel));
216
272
  auxFiles.set(rel, parseOutline(text, rel));
217
273
  } catch {
218
274
  // unreadable/unparseable aux file: its refs are simply not counted
@@ -308,7 +364,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
308
364
  const targetFiles = new Map<string, OutlineNode[]>();
309
365
  for (const rel of discoverOverflowTargets(root)) {
310
366
  try {
311
- targetFiles.set(rel, parseOutline(await Bun.file(join(root, rel)).text(), rel));
367
+ targetFiles.set(rel, parseOutline(await readText(join(root, rel)), rel));
312
368
  } catch {
313
369
  // unreadable overflow target: skipped
314
370
  }
@@ -326,7 +382,7 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
326
382
  const body = desired.get(rel) ?? null;
327
383
  const rewritten = rewriteRefBy(source, body);
328
384
  if (rewritten !== source) {
329
- await Bun.write(join(root, rel), rewritten);
385
+ await writeText(join(root, rel), rewritten);
330
386
  specSources.set(rel, rewritten);
331
387
  backPointersUpdated++;
332
388
  }
@@ -363,7 +419,13 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
363
419
  let nodeCount = 0;
364
420
  let depthMax = 0;
365
421
  for (const nodes of specFiles.values()) {
366
- nodeCount += flattenNodes(nodes).length;
422
+ // Issue #8: report REAL nodes only — synthetic "(table)"/"(code fence)"
423
+ // placeholders (leading table/fence before the first bullet) are not
424
+ // user content and inflated the header count and budget estimates.
425
+ nodeCount += realNodes(nodes).length;
426
+ // Depth keeps the full tree: a synthetic root sits at indent 0, so it can
427
+ // never raise depthMax; a workspace containing only phantoms now has
428
+ // nodeCount 0 and correctly reports maxDepth 0 (§35 empty-workspace case).
367
429
  depthMax = Math.max(depthMax, outlineMaxDepth(nodes));
368
430
  }
369
431
  const refsTotal = [...graph.forward.values()].reduce((a, ts) => a + ts.length, 0);
@@ -1,10 +1,11 @@
1
1
  import { join } from 'path';
2
2
  import { renameSync } from 'fs';
3
- import type { DoneResult, OutlineNode } from '../types';
4
- import { resolveWorkspaceRoot, mkdirp, isFile, dirExists, globFiles } from '../core/fs';
5
- import { parseOutline, flattenNodes } from '../core/outline';
6
- import { checkWorkspace, type CheckArgs } from './check';
7
- import { parseArgs, type FlagSpec } from '../core/args';
3
+ import type { DoneResult, OutlineNode } from '../types.ts';
4
+ import { readText } from '../core/runtime.ts';
5
+ import { resolveWorkspaceRoot, mkdirp, isFile, dirExists, globFiles } from '../core/fs.ts';
6
+ import { parseOutline, flattenNodes } from '../core/outline.ts';
7
+ import { checkWorkspace, type CheckArgs } from './check.ts';
8
+ import { parseArgs, type FlagSpec } from '../core/args.ts';
8
9
 
9
10
  export interface DoneArgs {
10
11
  name: string;
@@ -105,7 +106,7 @@ export async function run(args: string[]): Promise<DoneResult> {
105
106
  let taskNodes: OutlineNode[] = [];
106
107
  let flat: OutlineNode[] = [];
107
108
  try {
108
- taskNodes = parseOutline(await Bun.file(taskFile).text(), `_tasks/${name}.md`);
109
+ taskNodes = parseOutline(await readText(taskFile), `_tasks/${name}.md`);
109
110
  flat = flattenNodes(taskNodes);
110
111
  } catch {
111
112
  return failResult(name, `cannot parse _tasks/${name}.md — check for tab indentation or malformed content`);
@@ -1,13 +1,14 @@
1
1
  import { join, basename, relative } from 'path';
2
- import type { ExportResult, ExportFormat, OutlineNode, ExternalNode } from '../types';
2
+ import type { ExportResult, ExportFormat, OutlineNode, ExternalNode } from '../types.ts';
3
+ import { readText, writeText } from '../core/runtime.ts';
3
4
  import {
4
5
  resolveWorkspaceRoot, discoverSpecFiles, discoverActiveTasks, discoverAdrs,
5
6
  mkdirp, dirExists, exists,
6
- } from '../core/fs';
7
- import { parseOutline } from '../core/outline';
8
- import { serializeOpml } from '../converters/opml';
9
- import { serializeLogseq } from '../converters/logseq';
10
- import { serializeObsidian } from '../converters/obsidian';
7
+ } from '../core/fs.ts';
8
+ import { parseOutline } from '../core/outline.ts';
9
+ import { serializeOpml } from '../converters/opml.ts';
10
+ import { serializeLogseq } from '../converters/logseq.ts';
11
+ import { serializeObsidian } from '../converters/obsidian.ts';
11
12
 
12
13
  export interface ExportArgs {
13
14
  format: ExportFormat;
@@ -185,7 +186,7 @@ export async function run(args: string[]): Promise<ExportResult> {
185
186
  for (const rel of sources) {
186
187
  let text = '';
187
188
  try {
188
- text = await Bun.file(join(workspace, rel)).text();
189
+ text = await readText(join(workspace, rel));
189
190
  } catch {
190
191
  continue;
191
192
  }
@@ -202,7 +203,7 @@ export async function run(args: string[]): Promise<ExportResult> {
202
203
  if (!opts.dryRun) {
203
204
  try {
204
205
  mkdirp(fmtDir);
205
- await Bun.write(join(fmtDir, outputFileName(rel, f)), content);
206
+ await writeText(join(fmtDir, outputFileName(rel, f)), content);
206
207
  } catch (e) {
207
208
  const code = (e as NodeJS.ErrnoException | null)?.code;
208
209
  if (code === 'ENOTDIR' || code === 'EEXIST') return notDirError(fmtDir);
@@ -2,15 +2,16 @@ import { join, basename, dirname } from 'path';
2
2
  import { readdirSync } from 'fs';
3
3
  import type {
4
4
  ImportResult, ImportFormat, ImportConflict, MergeStrategy, ExternalNode,
5
- } from '../types';
6
- import { resolveWorkspaceRoot, discoverSpecFiles, mkdirp, isFile, dirExists } from '../core/fs';
7
- import { convertArrowRefs, parseOpml, parseOpmlTitle } from '../converters/opml';
8
- import { parseLogseq } from '../converters/logseq';
9
- import { parseObsidian, stripFrontmatter } from '../converters/obsidian';
5
+ } from '../types.ts';
6
+ import { readText, writeText } from '../core/runtime.ts';
7
+ import { resolveWorkspaceRoot, discoverSpecFiles, mkdirp, isFile, dirExists } from '../core/fs.ts';
8
+ import { convertArrowRefs, parseOpml, parseOpmlTitle } from '../converters/opml.ts';
9
+ import { parseLogseq } from '../converters/logseq.ts';
10
+ import { parseObsidian, stripFrontmatter } from '../converters/obsidian.ts';
10
11
  import {
11
12
  serializeToCans, parseFromCans, stripMetadata, parseCheckbox,
12
13
  extractOverflowContent, type OverflowExtraction,
13
- } from '../converters/shared';
14
+ } from '../converters/shared.ts';
14
15
 
15
16
  export interface ImportArgs {
16
17
  format: ImportFormat;
@@ -325,7 +326,7 @@ async function findExistingByRootText(targetDir: string, imported: ExternalNode[
325
326
  for (const rel of discoverSpecFiles(targetDir)) {
326
327
  let text = '';
327
328
  try {
328
- text = await Bun.file(join(targetDir, rel)).text();
329
+ text = await readText(join(targetDir, rel));
329
330
  } catch {
330
331
  continue;
331
332
  }
@@ -400,7 +401,7 @@ export async function run(args: string[]): Promise<ImportResult> {
400
401
  for (const src of files) {
401
402
  let text = '';
402
403
  try {
403
- text = await Bun.file(src).text();
404
+ text = await readText(src);
404
405
  } catch {
405
406
  continue;
406
407
  }
@@ -454,7 +455,7 @@ export async function run(args: string[]): Promise<ImportResult> {
454
455
  if (existingRel !== null) {
455
456
  const absTarget = join(workspace, existingRel);
456
457
  const outcome = mergeInto(
457
- await Bun.file(absTarget).text(),
458
+ await readText(absTarget),
458
459
  imported,
459
460
  opts.mergeStrategy,
460
461
  existingRel,
@@ -462,11 +463,11 @@ export async function run(args: string[]): Promise<ImportResult> {
462
463
  conflicts.push(...outcome.conflicts);
463
464
  if (outcome.content !== null) {
464
465
  if (!opts.dryRun) {
465
- await Bun.write(absTarget, canonicalizeRefTargets(outcome.content));
466
+ await writeText(absTarget, canonicalizeRefTargets(outcome.content));
466
467
  for (const ovf of overflow) {
467
468
  const ovfAbs = join(workspace, ovf.overflowFile);
468
469
  mkdirp(dirname(ovfAbs));
469
- await Bun.write(ovfAbs, `${ovf.content}\n`);
470
+ await writeText(ovfAbs, `${ovf.content}\n`);
470
471
  }
471
472
  }
472
473
  merged.push(existingRel);
@@ -485,11 +486,11 @@ export async function run(args: string[]): Promise<ImportResult> {
485
486
  const cansText = canonicalizeRefTargets(serializeToCans(imported));
486
487
  if (!opts.dryRun) {
487
488
  mkdirp(dirname(absTarget));
488
- await Bun.write(absTarget, cansText);
489
+ await writeText(absTarget, cansText);
489
490
  for (const ovf of overflow) {
490
491
  const ovfAbs = join(workspace, ovf.overflowFile);
491
492
  mkdirp(dirname(ovfAbs));
492
- await Bun.write(ovfAbs, `${ovf.content}\n`);
493
+ await writeText(ovfAbs, `${ovf.content}\n`);
493
494
  }
494
495
  }
495
496
  newFiles.push(relName);
@@ -1,12 +1,13 @@
1
1
  import { basename, dirname, join, relative } from 'path';
2
- import type { InitResult } from '../types';
3
- import { resolveWorkspaceRoot, resolveInitTarget, mkdirp, exists, dirExists } from '../core/fs';
4
- import { parseArgs, type FlagSpec } from '../core/args';
2
+ import type { InitResult } from '../types.ts';
3
+ import { readText, writeText, dirFromUrl } from '../core/runtime.ts';
4
+ import { resolveWorkspaceRoot, resolveInitTarget, mkdirp, exists, dirExists } from '../core/fs.ts';
5
+ import { parseArgs, type FlagSpec } from '../core/args.ts';
5
6
 
6
- const TEMPLATES_DIR = join(import.meta.dir, '..', '..', 'templates');
7
+ const TEMPLATES_DIR = join(dirFromUrl(import.meta.url), '..', '..', 'templates');
7
8
 
8
9
  async function readTemplate(name: string): Promise<string> {
9
- return await Bun.file(join(TEMPLATES_DIR, name)).text();
10
+ return await readText(join(TEMPLATES_DIR, name));
10
11
  }
11
12
 
12
13
  export interface InitArgs {
@@ -168,7 +169,7 @@ export async function run(args: string[]): Promise<InitResult> {
168
169
  skipped.push(entry.path);
169
170
  continue;
170
171
  }
171
- await Bun.write(abs, entry.content ?? '');
172
+ await writeText(abs, entry.content ?? '');
172
173
  created.push(entry.path);
173
174
  }
174
175