@decohere/workspace 0.0.1-dev.129 → 0.0.1-dev.131

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
@@ -4,10 +4,12 @@ Workspace and project model for Decohere tooling. Forge and Flux call it to find
4
4
 
5
5
  ## Workspace layout
6
6
 
7
- A **project** is a directory whose root contains a `package.json`. The workspace package discovers those files with `includeProjects` globs (cwd = the workspace root, usually `process.cwd()`).
7
+ A **project** is a directory whose root contains a `package.json`. The workspace package discovers those files with `includeProjects` globs from the workspace root: the folder the tool runs in. Nothing searches the folders above it; to run from elsewhere, pass `--root-dir <dir>` to `forge` or `flux`.
8
8
 
9
9
  The workspace root may also have a `package.json`. That file is workspace metadata (name/version). It is **not** a project unless it also matches `includeProjects`.
10
10
 
11
+ **Standalone projects.** A folder with a `package.json` and no `projects/` folder, and no `includeProjects` of your own, is one project: its root `package.json` is the package, with its sources at the root (build output, `node_modules` and `.git` excluded). Every tool works on that one package; `forge dependencies sync`, `dependencies report` and `paths` have nothing to do, since there is no other package. Adding a `projects/` folder turns it into a monorepo: move the sources into `projects/<name>/` with their own `package.json`.
12
+
11
13
  ### Defaults
12
14
 
13
15
  | Option | Default |
@@ -42,7 +44,7 @@ export default {
42
44
 
43
45
  Per-project settings go under `projects`, keyed by package name: `docker` (image, Dockerfile, tags, build args) and `ui` (the UI project a deployable ships, see [forge](../forge/README.md#shipping-the-ui-with-the-api)).
44
46
 
45
- `--config-file` on `forge` / `flux` selects a different config path. The default filename is still `.decohere.js`.
47
+ `--config-file` on `forge` / `flux` selects a different config path. The default is `.decohere.js` in the workspace root (`--root-dir`, else the current folder).
46
48
 
47
49
  ### Package aliases
48
50
 
@@ -54,6 +56,8 @@ For each discovered project, the workspace package maps `package.json` `name` to
54
56
 
55
57
  Root `tsconfig.json` `paths` override the same keys. In this repo they come from `forge paths`, which writes this map to `tsconfig.paths.json` for `tsc`.
56
58
 
59
+ `tsconfig.json` is read with [get-tsconfig](https://github.com/privatenumber/get-tsconfig), which follows `extends`, not with the TypeScript compiler: loading the compiler takes longer than the rest of a `flux run`. Transpiling uses only `paths` and `baseUrl` (made absolute). Forge's type emission, which needs every option as tsc sees it, parses `Tsconfig.filePath` with the compiler itself.
60
+
57
61
  Forge debug and Flux consume this map (SWC `jsc.paths` / resolve hooks). Why debug uses it instead of `node_modules` workspace links: [Forge — debug path aliases](../forge/README.md#debug-path-aliases-not-node_modules-links).
58
62
 
59
63
  ## When to use
@@ -12,6 +12,7 @@ const _nodepath = /*#__PURE__*/ _interop_require_default(require("node:path"));
12
12
  const _io = require("@decohere/core/io");
13
13
  const _io1 = require("@decohere/devkit/io");
14
14
  const _objects = require("@decohere/core/objects");
15
+ const _const = require("../const.js");
15
16
  const _errors = require("../errors.js");
16
17
  const _packagejsonmodel = require("../models/package-json.model.js");
17
18
  const _projectmodel = require("../models/project.model.js");
@@ -29,9 +30,39 @@ class ProjectFactory {
29
30
  ignore: ignoreProjects
30
31
  });
31
32
  const packageJsonFiles = ProjectFactory.filterNestedPackageJsonFiles(discoveredPackageJsonFiles);
33
+ if (packageJsonFiles.length === 0 && await ProjectFactory.isStandalone(config)) {
34
+ return [
35
+ await ProjectFactory.standaloneProject(config)
36
+ ];
37
+ }
32
38
  const projects = await Promise.all(packageJsonFiles.map((file)=>ProjectFactory.projectFrom(file, config)));
33
39
  return projects.sort((a, b)=>a.name.localeCompare(b.name));
34
40
  }
41
+ /**
42
+ * A standalone project: no `projects/` folder and no `includeProjects` of the user's own, but
43
+ * a root `package.json`. That `package.json` is then the one project, at the root.
44
+ */ static async isStandalone(config) {
45
+ const rootDir = config.rootDir ?? "./";
46
+ const defaultInclude = JSON.stringify(config.includeProjects) === JSON.stringify(_const.WorkspaceDefaults.includeProjects);
47
+ return defaultInclude && !await _io.io.directoryExists(_nodepath.default.join(rootDir, _const.PROJECTS_DIR)) && await _io.io.fileExists(_nodepath.default.join(rootDir, _const.PACKAGE_JSON));
48
+ }
49
+ /** The root `package.json` as the one project. Build output and `.git` aren't its sources. */ static async standaloneProject(config) {
50
+ const [packageJsonFile] = await (0, _io1.findFiles)([
51
+ _const.PACKAGE_JSON
52
+ ], {
53
+ cwd: config.rootDir ?? "./"
54
+ });
55
+ const outDir = (config.outDir ?? _const.WorkspaceDefaults.outDir).replace(/[/\\]+$/, "");
56
+ const ignore = [
57
+ ...config.ignore ?? [],
58
+ `${outDir}/**`,
59
+ ".git/**"
60
+ ];
61
+ return ProjectFactory.projectFrom(packageJsonFile, {
62
+ ...config,
63
+ ignore
64
+ });
65
+ }
35
66
  static filterNestedPackageJsonFiles(files) {
36
67
  const normalize = (filePath)=>filePath.replaceAll("\\", "/");
37
68
  const packageJsonSet = new Set(files.map((file)=>normalize(file.relativePath)));
@@ -48,9 +48,6 @@ _export(exports, {
48
48
  get WorkspaceFactory () {
49
49
  return _workspacefactory.WorkspaceFactory;
50
50
  },
51
- get findWorkspaceRoot () {
52
- return _findworkspaceroot.findWorkspaceRoot;
53
- },
54
51
  get packageAliases () {
55
52
  return _packagealiases.packageAliases;
56
53
  },
@@ -61,7 +58,6 @@ _export(exports, {
61
58
  const _const = require("./const.js");
62
59
  const _errors = require("./errors.js");
63
60
  const _configfactory = require("./factories/config.factory.js");
64
- const _findworkspaceroot = require("./factories/find-workspace-root.js");
65
61
  const _projectfactory = require("./factories/project.factory.js");
66
62
  const _workspacefactory = require("./factories/workspace.factory.js");
67
63
  const _haspackagejsonmixin = require("./models/has-package-json.mixin.js");
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Parses JSON with comments and trailing commas, as `tsconfig.json` allows. An empty file is
3
+ * `{}`, as tsc reads it. Throws a `SyntaxError` on anything else that isn't valid JSON.
4
+ */ "use strict";
5
+ Object.defineProperty(exports, "__esModule", {
6
+ value: true
7
+ });
8
+ Object.defineProperty(exports, "parseJsonc", {
9
+ enumerable: true,
10
+ get: function() {
11
+ return parseJsonc;
12
+ }
13
+ });
14
+ function parseJsonc(text) {
15
+ if (text.trim() === "") return {};
16
+ return JSON.parse(stripJsonc(text));
17
+ }
18
+ function stripJsonc(text) {
19
+ let out = "";
20
+ let i = 0;
21
+ while(i < text.length){
22
+ const char = text[i];
23
+ const next = text[i + 1];
24
+ if (char === '"') {
25
+ const end = stringEnd(text, i);
26
+ out += text.slice(i, end);
27
+ i = end;
28
+ } else if (char === "/" && next === "/") {
29
+ const end = text.indexOf("\n", i);
30
+ i = end === -1 ? text.length : end;
31
+ } else if (char === "/" && next === "*") {
32
+ const end = text.indexOf("*/", i + 2);
33
+ if (end === -1) throw new SyntaxError("Unterminated comment in JSON");
34
+ out += " ";
35
+ i = end + 2;
36
+ } else if (char === "}" || char === "]") {
37
+ // Drop a trailing comma before the closing bracket.
38
+ out = out.trimEnd();
39
+ if (out.endsWith(",")) out = out.slice(0, -1);
40
+ out += char;
41
+ i++;
42
+ } else {
43
+ out += char;
44
+ i++;
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+ /** Index just past the string starting at `start` (a `"`); an unclosed string runs to the end. */ function stringEnd(text, start) {
50
+ let i = start + 1;
51
+ while(i < text.length){
52
+ if (text[i] === "\\") i += 2;
53
+ else if (text[i] === '"') return i + 1;
54
+ else i++;
55
+ }
56
+ return text.length;
57
+ }
@@ -14,7 +14,7 @@ const _caching = require("@decohere/core/caching");
14
14
  const _extensibility = require("@decohere/core/extensibility");
15
15
  const _io = require("@decohere/devkit/io");
16
16
  const _picomatch = /*#__PURE__*/ _interop_require_default(require("picomatch"));
17
- const _rxjs = require("rxjs");
17
+ const _system = require("@decohere/core/system");
18
18
  const _errors = require("../errors.js");
19
19
  const _haspackagejsonmixin = require("./has-package-json.mixin.js");
20
20
  const _hastsconfigmixin = require("./has-tsconfig.mixin.js");
@@ -36,7 +36,7 @@ function _ts_metadata(k, v) {
36
36
  }
37
37
  class Project extends (0, _extensibility.mixwith)(_haspackagejsonmixin.HasPackageJson, _hastsconfigmixin.HasTsConfig) {
38
38
  packageJson;
39
- #projectFileChangedSubject = new _rxjs.Subject();
39
+ #projectFileChangedSubject = new _system.Subject();
40
40
  #sourceFiles = new Map();
41
41
  #projectConfig;
42
42
  #name;
@@ -19,24 +19,14 @@ _export(exports, {
19
19
  const _nodefs = /*#__PURE__*/ _interop_require_default(require("node:fs"));
20
20
  const _nodepath = /*#__PURE__*/ _interop_require_default(require("node:path"));
21
21
  const _io = require("@decohere/core/io");
22
- const _typescript = /*#__PURE__*/ _interop_require_default(require("typescript"));
22
+ const _gettsconfig = require("get-tsconfig");
23
23
  const _errors = require("../errors.js");
24
+ const _jsonc = require("./jsonc.js");
24
25
  function _interop_require_default(obj) {
25
26
  return obj && obj.__esModule ? obj : {
26
27
  default: obj
27
28
  };
28
29
  }
29
- const host = _typescript.default.sys;
30
- host.readFile = (file)=>{
31
- return _nodefs.default.readFileSync(file, {
32
- encoding: "utf8"
33
- });
34
- };
35
- host.readDirectory = (directoryName)=>{
36
- return _nodefs.default.readdirSync(directoryName, {
37
- withFileTypes: true
38
- }).filter((entry)=>entry.isDirectory()).map((entry)=>entry.name);
39
- };
40
30
  const DEFAULT_COMPILER_OPTIONS = {
41
31
  emitDeclarationOnly: true,
42
32
  declaration: true,
@@ -50,34 +40,34 @@ class Tsconfig {
50
40
  if (!await _io.io.fileExists(filePath)) {
51
41
  throw new _errors.WorkspaceConfigurationError(`TSConfig not found ${filePath}`);
52
42
  }
53
- const configFileText = await _io.io.readFile(filePath);
54
- const result = _typescript.default.parseConfigFileTextToJson(filePath, configFileText);
55
- if (result.error) {
56
- const errorMessage = result.error.messageText.toString();
57
- throw new _errors.WorkspaceConfigurationError(`Error parsing ${filePath}. ${errorMessage}`);
58
- }
59
- const dirName = _nodepath.default.resolve(_nodepath.default.dirname(filePath));
60
- const configParseResult = _typescript.default.parseJsonConfigFileContent(result.config, host, dirName);
61
- return new Tsconfig({
62
- compilerOptions: configParseResult.options,
63
- errors: configParseResult.errors
64
- });
43
+ return Tsconfig.read(filePath);
65
44
  }
66
45
  static parseSync(filePath) {
67
46
  if (!Tsconfig.fileExistsSync(filePath)) {
68
47
  throw new _errors.WorkspaceConfigurationError(`TSConfig not found ${filePath}`);
69
48
  }
70
- const configFileText = _nodefs.default.readFileSync(filePath, "utf8");
71
- const result = _typescript.default.parseConfigFileTextToJson(filePath, configFileText);
72
- if (result.error) {
73
- const errorMessage = result.error.messageText.toString();
74
- throw new _errors.WorkspaceConfigurationError(`Error parsing ${filePath}. ${errorMessage}`);
49
+ return Tsconfig.read(filePath);
50
+ }
51
+ static read(filePath) {
52
+ const fullPath = _nodepath.default.resolve(filePath);
53
+ let compilerOptions;
54
+ try {
55
+ // get-tsconfig reads broken JSON as `{}`; check the file itself first.
56
+ (0, _jsonc.parseJsonc)(_nodefs.default.readFileSync(fullPath, "utf8"));
57
+ compilerOptions = (0, _gettsconfig.parseTsconfig)(fullPath).compilerOptions ?? {};
58
+ } catch (error) {
59
+ const message = error instanceof Error ? error.message : String(error);
60
+ throw new _errors.WorkspaceConfigurationError(`Error parsing ${filePath}. ${message}`, {
61
+ cause: error
62
+ });
75
63
  }
76
- const dirName = _nodepath.default.resolve(_nodepath.default.dirname(filePath));
77
- const configParseResult = _typescript.default.parseJsonConfigFileContent(result.config, host, dirName);
64
+ const { baseUrl } = compilerOptions;
78
65
  return new Tsconfig({
79
- compilerOptions: configParseResult.options,
80
- errors: configParseResult.errors
66
+ filePath: fullPath,
67
+ compilerOptions: baseUrl == null ? compilerOptions : {
68
+ ...compilerOptions,
69
+ baseUrl: _nodepath.default.resolve(_nodepath.default.dirname(fullPath), baseUrl)
70
+ }
81
71
  });
82
72
  }
83
73
  static fileExistsSync(filePath) {
@@ -92,6 +82,6 @@ class Tsconfig {
92
82
  }){
93
83
  Object.assign(this, data);
94
84
  }
85
+ /** Absolute path of the file read; undefined for {@link Tsconfig.default}. */ filePath;
95
86
  compilerOptions;
96
- errors;
97
87
  }
@@ -11,7 +11,7 @@ Object.defineProperty(exports, "Workspace", {
11
11
  const _decorators = require("@decohere/crucible/decorators");
12
12
  const _collections = require("@decohere/core/collections");
13
13
  const _extensibility = require("@decohere/core/extensibility");
14
- const _rxjs = require("rxjs");
14
+ const _system = require("@decohere/core/system");
15
15
  const _errors = require("../errors.js");
16
16
  const _haspackagejsonmixin = require("./has-package-json.mixin.js");
17
17
  const _hastsconfigmixin = require("./has-tsconfig.mixin.js");
@@ -38,7 +38,7 @@ class Workspace extends (0, _extensibility.mixwith)(_haspackagejsonmixin.HasPack
38
38
  * of every file: the Vitest plugin asks once per transformed file. Keyed by the list
39
39
  * `getAllFiles` returns, so a new list (after its cache is flushed) gets a new set.
40
40
  */ #sourcePaths = new WeakMap();
41
- #projectFileChangedSubject = new _rxjs.Subject();
41
+ #projectFileChangedSubject = new _system.Subject();
42
42
  #projectWatchSubscriptions = new Map();
43
43
  #watchedProjects = [];
44
44
  constructor(options = {}){
@@ -68,6 +68,13 @@ class Workspace extends (0, _extensibility.mixwith)(_haspackagejsonmixin.HasPack
68
68
  get rootDir() {
69
69
  return this.#config.rootDir ?? "./";
70
70
  }
71
+ /**
72
+ * One project, whose `package.json` is the root's: a folder without `projects/`. Commands
73
+ * that write a project's manifest from the root's (dependency sync) or map projects to each
74
+ * other (paths) have nothing to do then.
75
+ */ get isStandalone() {
76
+ return this.#projectSeeds.length === 1 && this.#projectSeeds[0]?.config?.projectDir === ".";
77
+ }
71
78
  get packageJsonDirectory() {
72
79
  return this.rootDir;
73
80
  }
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import { io } from "@decohere/core/io";
3
3
  import { findFiles } from "@decohere/devkit/io";
4
4
  import { merge } from "@decohere/core/objects";
5
+ import { PACKAGE_JSON, PROJECTS_DIR, WorkspaceDefaults } from "../const.js";
5
6
  import { WorkspaceConfigurationError } from "../errors.js";
6
7
  import { PackageJson } from "../models/package-json.model.js";
7
8
  import { Project } from "../models/project.model.js";
@@ -14,9 +15,39 @@ export class ProjectFactory {
14
15
  ignore: ignoreProjects
15
16
  });
16
17
  const packageJsonFiles = ProjectFactory.filterNestedPackageJsonFiles(discoveredPackageJsonFiles);
18
+ if (packageJsonFiles.length === 0 && await ProjectFactory.isStandalone(config)) {
19
+ return [
20
+ await ProjectFactory.standaloneProject(config)
21
+ ];
22
+ }
17
23
  const projects = await Promise.all(packageJsonFiles.map((file)=>ProjectFactory.projectFrom(file, config)));
18
24
  return projects.sort((a, b)=>a.name.localeCompare(b.name));
19
25
  }
26
+ /**
27
+ * A standalone project: no `projects/` folder and no `includeProjects` of the user's own, but
28
+ * a root `package.json`. That `package.json` is then the one project, at the root.
29
+ */ static async isStandalone(config) {
30
+ const rootDir = config.rootDir ?? "./";
31
+ const defaultInclude = JSON.stringify(config.includeProjects) === JSON.stringify(WorkspaceDefaults.includeProjects);
32
+ return defaultInclude && !await io.directoryExists(path.join(rootDir, PROJECTS_DIR)) && await io.fileExists(path.join(rootDir, PACKAGE_JSON));
33
+ }
34
+ /** The root `package.json` as the one project. Build output and `.git` aren't its sources. */ static async standaloneProject(config) {
35
+ const [packageJsonFile] = await findFiles([
36
+ PACKAGE_JSON
37
+ ], {
38
+ cwd: config.rootDir ?? "./"
39
+ });
40
+ const outDir = (config.outDir ?? WorkspaceDefaults.outDir).replace(/[/\\]+$/, "");
41
+ const ignore = [
42
+ ...config.ignore ?? [],
43
+ `${outDir}/**`,
44
+ ".git/**"
45
+ ];
46
+ return ProjectFactory.projectFrom(packageJsonFile, {
47
+ ...config,
48
+ ignore
49
+ });
50
+ }
20
51
  static filterNestedPackageJsonFiles(files) {
21
52
  const normalize = (filePath)=>filePath.replaceAll("\\", "/");
22
53
  const packageJsonSet = new Set(files.map((file)=>normalize(file.relativePath)));
package/es6/src/index.js CHANGED
@@ -1,7 +1,6 @@
1
1
  export { WORKSPACE_CONFIG, WorkspaceDefaults } from "./const.js";
2
2
  export { WorkspaceConfigurationError } from "./errors.js";
3
3
  export { UserConfigFactory } from "./factories/config.factory.js";
4
- export { findWorkspaceRoot } from "./factories/find-workspace-root.js";
5
4
  export { ProjectFactory } from "./factories/project.factory.js";
6
5
  export { WorkspaceFactory } from "./factories/workspace.factory.js";
7
6
  export { HasPackageJson } from "./models/has-package-json.mixin.js";
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Parses JSON with comments and trailing commas, as `tsconfig.json` allows. An empty file is
3
+ * `{}`, as tsc reads it. Throws a `SyntaxError` on anything else that isn't valid JSON.
4
+ */ export function parseJsonc(text) {
5
+ if (text.trim() === "") return {};
6
+ return JSON.parse(stripJsonc(text));
7
+ }
8
+ function stripJsonc(text) {
9
+ let out = "";
10
+ let i = 0;
11
+ while(i < text.length){
12
+ const char = text[i];
13
+ const next = text[i + 1];
14
+ if (char === '"') {
15
+ const end = stringEnd(text, i);
16
+ out += text.slice(i, end);
17
+ i = end;
18
+ } else if (char === "/" && next === "/") {
19
+ const end = text.indexOf("\n", i);
20
+ i = end === -1 ? text.length : end;
21
+ } else if (char === "/" && next === "*") {
22
+ const end = text.indexOf("*/", i + 2);
23
+ if (end === -1) throw new SyntaxError("Unterminated comment in JSON");
24
+ out += " ";
25
+ i = end + 2;
26
+ } else if (char === "}" || char === "]") {
27
+ // Drop a trailing comma before the closing bracket.
28
+ out = out.trimEnd();
29
+ if (out.endsWith(",")) out = out.slice(0, -1);
30
+ out += char;
31
+ i++;
32
+ } else {
33
+ out += char;
34
+ i++;
35
+ }
36
+ }
37
+ return out;
38
+ }
39
+ /** Index just past the string starting at `start` (a `"`); an unclosed string runs to the end. */ function stringEnd(text, start) {
40
+ let i = start + 1;
41
+ while(i < text.length){
42
+ if (text[i] === "\\") i += 2;
43
+ else if (text[i] === '"') return i + 1;
44
+ else i++;
45
+ }
46
+ return text.length;
47
+ }
@@ -13,7 +13,7 @@ import { cache, memoize } from "@decohere/core/caching";
13
13
  import { mixwith } from "@decohere/core/extensibility";
14
14
  import { findFiles, WatchEvents, watch as watchDirectory } from "@decohere/devkit/io";
15
15
  import picomatch from "picomatch";
16
- import { Subject } from "rxjs";
16
+ import { Subject } from "@decohere/core/system";
17
17
  import { WorkspaceConfigurationError } from "../errors.js";
18
18
  import { HasPackageJson } from "./has-package-json.mixin.js";
19
19
  import { HasTsConfig } from "./has-tsconfig.mixin.js";
@@ -1,25 +1,18 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { io } from "@decohere/core/io";
4
- import ts from "typescript";
4
+ import { parseTsconfig } from "get-tsconfig";
5
5
  import { WorkspaceConfigurationError } from "../errors.js";
6
- const host = ts.sys;
7
- host.readFile = (file)=>{
8
- return fs.readFileSync(file, {
9
- encoding: "utf8"
10
- });
11
- };
12
- host.readDirectory = (directoryName)=>{
13
- return fs.readdirSync(directoryName, {
14
- withFileTypes: true
15
- }).filter((entry)=>entry.isDirectory()).map((entry)=>entry.name);
16
- };
6
+ import { parseJsonc } from "./jsonc.js";
17
7
  export const DEFAULT_COMPILER_OPTIONS = {
18
8
  emitDeclarationOnly: true,
19
9
  declaration: true,
20
10
  baseUrl: "./"
21
11
  };
22
- export class Tsconfig {
12
+ /**
13
+ * A `tsconfig.json`, read without the TypeScript compiler so running a file doesn't load it.
14
+ * Code that needs the compiler's own parse (type emission) parses `filePath` itself.
15
+ */ export class Tsconfig {
23
16
  static default() {
24
17
  return new Tsconfig();
25
18
  }
@@ -27,34 +20,34 @@ export class Tsconfig {
27
20
  if (!await io.fileExists(filePath)) {
28
21
  throw new WorkspaceConfigurationError(`TSConfig not found ${filePath}`);
29
22
  }
30
- const configFileText = await io.readFile(filePath);
31
- const result = ts.parseConfigFileTextToJson(filePath, configFileText);
32
- if (result.error) {
33
- const errorMessage = result.error.messageText.toString();
34
- throw new WorkspaceConfigurationError(`Error parsing ${filePath}. ${errorMessage}`);
35
- }
36
- const dirName = path.resolve(path.dirname(filePath));
37
- const configParseResult = ts.parseJsonConfigFileContent(result.config, host, dirName);
38
- return new Tsconfig({
39
- compilerOptions: configParseResult.options,
40
- errors: configParseResult.errors
41
- });
23
+ return Tsconfig.read(filePath);
42
24
  }
43
25
  static parseSync(filePath) {
44
26
  if (!Tsconfig.fileExistsSync(filePath)) {
45
27
  throw new WorkspaceConfigurationError(`TSConfig not found ${filePath}`);
46
28
  }
47
- const configFileText = fs.readFileSync(filePath, "utf8");
48
- const result = ts.parseConfigFileTextToJson(filePath, configFileText);
49
- if (result.error) {
50
- const errorMessage = result.error.messageText.toString();
51
- throw new WorkspaceConfigurationError(`Error parsing ${filePath}. ${errorMessage}`);
29
+ return Tsconfig.read(filePath);
30
+ }
31
+ static read(filePath) {
32
+ const fullPath = path.resolve(filePath);
33
+ let compilerOptions;
34
+ try {
35
+ // get-tsconfig reads broken JSON as `{}`; check the file itself first.
36
+ parseJsonc(fs.readFileSync(fullPath, "utf8"));
37
+ compilerOptions = parseTsconfig(fullPath).compilerOptions ?? {};
38
+ } catch (error) {
39
+ const message = error instanceof Error ? error.message : String(error);
40
+ throw new WorkspaceConfigurationError(`Error parsing ${filePath}. ${message}`, {
41
+ cause: error
42
+ });
52
43
  }
53
- const dirName = path.resolve(path.dirname(filePath));
54
- const configParseResult = ts.parseJsonConfigFileContent(result.config, host, dirName);
44
+ const { baseUrl } = compilerOptions;
55
45
  return new Tsconfig({
56
- compilerOptions: configParseResult.options,
57
- errors: configParseResult.errors
46
+ filePath: fullPath,
47
+ compilerOptions: baseUrl == null ? compilerOptions : {
48
+ ...compilerOptions,
49
+ baseUrl: path.resolve(path.dirname(fullPath), baseUrl)
50
+ }
58
51
  });
59
52
  }
60
53
  static fileExistsSync(filePath) {
@@ -69,6 +62,6 @@ export class Tsconfig {
69
62
  }){
70
63
  Object.assign(this, data);
71
64
  }
65
+ /** Absolute path of the file read; undefined for {@link Tsconfig.default}. */ filePath;
72
66
  compilerOptions;
73
- errors;
74
67
  }
@@ -10,7 +10,7 @@ function _ts_metadata(k, v) {
10
10
  import { injectable } from "@decohere/crucible/decorators";
11
11
  import { collections } from "@decohere/core/collections";
12
12
  import { mixwith } from "@decohere/core/extensibility";
13
- import { Subject } from "rxjs";
13
+ import { Subject } from "@decohere/core/system";
14
14
  import { WorkspaceConfigurationError } from "../errors.js";
15
15
  import { HasPackageJson } from "./has-package-json.mixin.js";
16
16
  import { HasTsConfig } from "./has-tsconfig.mixin.js";
@@ -58,6 +58,13 @@ export class Workspace extends mixwith(HasPackageJson, HasTsConfig) {
58
58
  get rootDir() {
59
59
  return this.#config.rootDir ?? "./";
60
60
  }
61
+ /**
62
+ * One project, whose `package.json` is the root's: a folder without `projects/`. Commands
63
+ * that write a project's manifest from the root's (dependency sync) or map projects to each
64
+ * other (paths) have nothing to do then.
65
+ */ get isStandalone() {
66
+ return this.#projectSeeds.length === 1 && this.#projectSeeds[0]?.config?.projectDir === ".";
67
+ }
61
68
  get packageJsonDirectory() {
62
69
  return this.rootDir;
63
70
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decohere/workspace",
3
- "version": "0.0.1-dev.129",
3
+ "version": "0.0.1-dev.131",
4
4
  "description": "Workspace and project model primitives for monorepo tooling",
5
5
  "keywords": [
6
6
  "typescript",
@@ -33,12 +33,11 @@
33
33
  "test": "echo \"Error: no test specified\" && exit 1"
34
34
  },
35
35
  "dependencies": {
36
- "@decohere/core": "0.0.1-dev.129",
37
- "@decohere/crucible": "0.0.1-dev.129",
38
- "@decohere/devkit": "0.0.1-dev.129",
39
- "picomatch": "^4.0.4",
40
- "rxjs": "^7.8.2",
41
- "typescript": "^6.0.3"
36
+ "@decohere/core": "0.0.1-dev.131",
37
+ "@decohere/crucible": "0.0.1-dev.131",
38
+ "@decohere/devkit": "0.0.1-dev.131",
39
+ "get-tsconfig": "^4.14.3",
40
+ "picomatch": "^4.0.4"
42
41
  },
43
42
  "devDependencies": {}
44
43
  }
@@ -2,6 +2,13 @@ import { Project, type ProjectSeed } from "../models/project.model.js";
2
2
  import type { WorkspaceOptions } from "../models/workspace-config.type.js";
3
3
  export declare class ProjectFactory {
4
4
  static getProjectSeeds(config: WorkspaceOptions): Promise<ProjectSeed[]>;
5
+ /**
6
+ * A standalone project: no `projects/` folder and no `includeProjects` of the user's own, but
7
+ * a root `package.json`. That `package.json` is then the one project, at the root.
8
+ */
9
+ private static isStandalone;
10
+ /** The root `package.json` as the one project. Build output and `.git` aren't its sources. */
11
+ private static standaloneProject;
5
12
  private static filterNestedPackageJsonFiles;
6
13
  static getProjects(config: WorkspaceOptions, configuration?: string): Promise<Project[]>;
7
14
  private static projectFrom;
@@ -1,7 +1,6 @@
1
1
  export { WORKSPACE_CONFIG, WorkspaceDefaults } from "./const.js";
2
2
  export { WorkspaceConfigurationError } from "./errors.js";
3
3
  export { UserConfigFactory } from "./factories/config.factory.js";
4
- export { findWorkspaceRoot } from "./factories/find-workspace-root.js";
5
4
  export { ProjectFactory } from "./factories/project.factory.js";
6
5
  export { WorkspaceFactory } from "./factories/workspace.factory.js";
7
6
  export { HasPackageJson } from "./models/has-package-json.mixin.js";
@@ -13,6 +12,7 @@ export type { ProjectConfig, ProjectSeed } from "./models/project.model.js";
13
12
  export { Project } from "./models/project.model.js";
14
13
  export { sourceExtensions } from "./models/source-extensions.js";
15
14
  export type { ProjectFileEvent } from "./models/project-file-event.js";
15
+ export type { CompilerOptions } from "./models/tsconfig.model.js";
16
16
  export { DEFAULT_COMPILER_OPTIONS, Tsconfig } from "./models/tsconfig.model.js";
17
17
  export { Workspace } from "./models/workspace.model.js";
18
18
  export type { Configuration, ProjectOptions, UiConfig, WorkspaceFactoryOptions, WorkspaceOptions, } from "./models/workspace-config.type.js";
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Parses JSON with comments and trailing commas, as `tsconfig.json` allows. An empty file is
3
+ * `{}`, as tsc reads it. Throws a `SyntaxError` on anything else that isn't valid JSON.
4
+ */
5
+ export declare function parseJsonc(text: string): unknown;
@@ -33,7 +33,7 @@ export declare class Project extends Project_base {
33
33
  get dockerConfig(): import("./workspace-config.type.js").DockerConfig;
34
34
  get uiConfig(): import("./workspace-config.type.js").UiConfig;
35
35
  get configurations(): Configuration[];
36
- get projectFileChanged$(): import("rxjs").Observable<ProjectFileEvent>;
36
+ get projectFileChanged$(): import("@decohere/core/system").Observable<ProjectFileEvent>;
37
37
  getAllFiles(): Promise<FileEx[]>;
38
38
  isSourceFile(file: FileEx): boolean;
39
39
  watch(configuration: string): Promise<Watcher>;
@@ -1,11 +1,19 @@
1
- import ts, { type CompilerOptions } from "typescript";
1
+ import { type TsConfigJson } from "get-tsconfig";
2
+ /** `compilerOptions` as written in `tsconfig.json`, after `extends`; `baseUrl` is absolute. */
3
+ export type CompilerOptions = TsConfigJson.CompilerOptions;
2
4
  export declare const DEFAULT_COMPILER_OPTIONS: CompilerOptions;
5
+ /**
6
+ * A `tsconfig.json`, read without the TypeScript compiler so running a file doesn't load it.
7
+ * Code that needs the compiler's own parse (type emission) parses `filePath` itself.
8
+ */
3
9
  export declare class Tsconfig {
4
10
  static default(): Tsconfig;
5
11
  static parse(filePath: string): Promise<Tsconfig>;
6
12
  static parseSync(filePath: string): Tsconfig;
13
+ private static read;
7
14
  private static fileExistsSync;
8
15
  constructor(data?: Partial<Tsconfig>);
16
+ /** Absolute path of the file read; undefined for {@link Tsconfig.default}. */
17
+ readonly filePath?: string;
9
18
  readonly compilerOptions: CompilerOptions;
10
- readonly errors: readonly ts.Diagnostic[];
11
19
  }
@@ -19,6 +19,12 @@ export declare class Workspace extends Workspace_base {
19
19
  protected get projectSeeds(): readonly ProjectSeed[];
20
20
  getProjects(configuration: string): readonly Project[];
21
21
  get rootDir(): string;
22
+ /**
23
+ * One project, whose `package.json` is the root's: a folder without `projects/`. Commands
24
+ * that write a project's manifest from the root's (dependency sync) or map projects to each
25
+ * other (paths) have nothing to do then.
26
+ */
27
+ get isStandalone(): boolean;
22
28
  get packageJsonDirectory(): string;
23
29
  protected get tsConfigDirectory(): string;
24
30
  protected get fallbackTsConfigDirectory(): string | undefined;
@@ -31,7 +37,7 @@ export declare class Workspace extends Workspace_base {
31
37
  watch(configuration: string): Promise<void>;
32
38
  stopWatching(): void;
33
39
  [Symbol.dispose](): void;
34
- get projectFileChanged$(): import("rxjs").Observable<ProjectFileEvent>;
40
+ get projectFileChanged$(): import("@decohere/core/system").Observable<ProjectFileEvent>;
35
41
  findProject(filePath: string, buildConfig: string): Promise<Project | undefined>;
36
42
  private sourcePathsOf;
37
43
  private validateWorkspace;
@@ -1,37 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", {
3
- value: true
4
- });
5
- Object.defineProperty(exports, "findWorkspaceRoot", {
6
- enumerable: true,
7
- get: function() {
8
- return findWorkspaceRoot;
9
- }
10
- });
11
- const _nodefs = require("node:fs");
12
- const _nodepath = /*#__PURE__*/ _interop_require_default(require("node:path"));
13
- const _const = require("../const.js");
14
- function _interop_require_default(obj) {
15
- return obj && obj.__esModule ? obj : {
16
- default: obj
17
- };
18
- }
19
- function findWorkspaceRoot(startDir) {
20
- const start = _nodepath.default.resolve(startDir);
21
- let dir = start;
22
- for(;;){
23
- if ((0, _nodefs.existsSync)(_nodepath.default.join(dir, _const.PACKAGE_JSON)) && isDirectory(_nodepath.default.join(dir, _const.PROJECTS_DIR))) {
24
- return dir;
25
- }
26
- const parent = _nodepath.default.dirname(dir);
27
- if (parent === dir) return start;
28
- dir = parent;
29
- }
30
- }
31
- function isDirectory(fullPath) {
32
- try {
33
- return (0, _nodefs.statSync)(fullPath).isDirectory();
34
- } catch {
35
- return false;
36
- }
37
- }
@@ -1,26 +0,0 @@
1
- import { existsSync, statSync } from "node:fs";
2
- import path from "node:path";
3
- import { PACKAGE_JSON, PROJECTS_DIR } from "../const.js";
4
- /**
5
- * The workspace root for a command run from `startDir`: the nearest directory at or above
6
- * it with a `package.json` and a `projects/` folder. Falls back to `startDir`, so running
7
- * from a package directory (`projects/api`) still sees the whole workspace.
8
- */ export function findWorkspaceRoot(startDir) {
9
- const start = path.resolve(startDir);
10
- let dir = start;
11
- for(;;){
12
- if (existsSync(path.join(dir, PACKAGE_JSON)) && isDirectory(path.join(dir, PROJECTS_DIR))) {
13
- return dir;
14
- }
15
- const parent = path.dirname(dir);
16
- if (parent === dir) return start;
17
- dir = parent;
18
- }
19
- }
20
- function isDirectory(fullPath) {
21
- try {
22
- return statSync(fullPath).isDirectory();
23
- } catch {
24
- return false;
25
- }
26
- }
@@ -1,6 +0,0 @@
1
- /**
2
- * The workspace root for a command run from `startDir`: the nearest directory at or above
3
- * it with a `package.json` and a `projects/` folder. Falls back to `startDir`, so running
4
- * from a package directory (`projects/api`) still sees the whole workspace.
5
- */
6
- export declare function findWorkspaceRoot(startDir: string): string;