@styx-api/cli 0.1.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/src/catalog.ts ADDED
@@ -0,0 +1,268 @@
1
+ import { readFileSync, readdirSync, statSync } from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ import type { Documentation, PackageMeta, ProjectMeta } from "@styx-api/core";
5
+
6
+ /**
7
+ * Loaded catalog: a project containing packages, each holding one or more
8
+ * apps. We always normalize to project > package > app even when the catalog
9
+ * root only describes a subset of the hierarchy (e.g. a single version dir).
10
+ */
11
+ export interface CatalogProject {
12
+ meta: ProjectMeta;
13
+ packages: CatalogPackage[];
14
+ /** Non-fatal issues found while walking the catalog (e.g. skipped stub apps). */
15
+ warnings: string[];
16
+ }
17
+
18
+ export interface CatalogPackage {
19
+ meta: PackageMeta;
20
+ apps: CatalogApp[];
21
+ }
22
+
23
+ export interface CatalogApp {
24
+ /** Stable identifier from `app.json#name`, used for the emitted module slug. */
25
+ name: string;
26
+ /** Absolute path to the descriptor file referenced by `app.json#source`. */
27
+ sourcePath: string;
28
+ sourceFormat?: string;
29
+ }
30
+
31
+ interface ProjectJson {
32
+ name?: string;
33
+ version?: string;
34
+ license?: string;
35
+ packages?: string[];
36
+ docs?: Documentation;
37
+ }
38
+
39
+ interface PackageJson {
40
+ name?: string;
41
+ versions?: string[];
42
+ default?: string;
43
+ docs?: Documentation;
44
+ }
45
+
46
+ interface VersionJson {
47
+ name?: string;
48
+ container?: string;
49
+ apps?: string[];
50
+ executables?: { required?: string[]; ignored?: string[] };
51
+ }
52
+
53
+ interface AppJson {
54
+ name?: string;
55
+ source?: { type?: string; path?: string };
56
+ docs?: Documentation;
57
+ }
58
+
59
+ function readJson<T>(file: string): T {
60
+ let raw: string;
61
+ try {
62
+ raw = readFileSync(file, "utf8");
63
+ } catch (e) {
64
+ throw new Error(`${file}: ${e instanceof Error ? e.message : String(e)}`);
65
+ }
66
+ try {
67
+ return JSON.parse(raw) as T;
68
+ } catch (e) {
69
+ throw new Error(`${file}: invalid JSON: ${e instanceof Error ? e.message : String(e)}`);
70
+ }
71
+ }
72
+
73
+ function exists(p: string): boolean {
74
+ try {
75
+ statSync(p);
76
+ return true;
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
82
+ function isDir(p: string): boolean {
83
+ try {
84
+ return statSync(p).isDirectory();
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Detect what level of the niwrap catalog a directory describes.
92
+ * Project dirs contain `project.json`, package dirs contain `package.json`,
93
+ * version dirs contain `version.json`, app dirs contain `app.json`.
94
+ */
95
+ export type CatalogLevel = "project" | "package" | "version" | "app";
96
+
97
+ export function detectLevel(dir: string): CatalogLevel | null {
98
+ if (exists(path.join(dir, "project.json"))) return "project";
99
+ if (exists(path.join(dir, "package.json"))) return "package";
100
+ if (exists(path.join(dir, "version.json"))) return "version";
101
+ if (exists(path.join(dir, "app.json"))) return "app";
102
+ return null;
103
+ }
104
+
105
+ /**
106
+ * Load one app descriptor. When `warnings` is supplied (catalog-walk mode), a
107
+ * stub app (`app.json` present but no `source.path`) is skipped with a warning
108
+ * instead of aborting the whole build - real catalogs list not-yet-wrapped tools
109
+ * alongside wrapped ones. Without `warnings` (a direct single-app target) the
110
+ * missing source is a hard error, since the user pointed straight at it.
111
+ */
112
+ function loadApp(appDir: string, warnings?: string[]): CatalogApp | null {
113
+ const appJsonPath = path.join(appDir, "app.json");
114
+ if (!exists(appJsonPath)) return null;
115
+ const app = readJson<AppJson>(appJsonPath);
116
+ const name = app.name ?? path.basename(appDir);
117
+ const sourceRel = app.source?.path;
118
+ if (!sourceRel) {
119
+ if (warnings) {
120
+ warnings.push(`${appJsonPath}: skipped (no source.path - not yet wrapped)`);
121
+ return null;
122
+ }
123
+ throw new Error(`${appJsonPath}: missing source.path`);
124
+ }
125
+ return {
126
+ name,
127
+ sourcePath: path.resolve(appDir, sourceRel),
128
+ sourceFormat: app.source?.type,
129
+ };
130
+ }
131
+
132
+ function loadVersion(versionDir: string, warnings?: string[]): CatalogPackage | null {
133
+ const versionPath = path.join(versionDir, "version.json");
134
+ if (!exists(versionPath)) return null;
135
+ const version = readJson<VersionJson>(versionPath);
136
+
137
+ // Sort the directory fallback so app order (and thus any shared-scope name
138
+ // dodging) is deterministic regardless of filesystem iteration order.
139
+ const appNames = version.apps ?? listSubdirs(versionDir).sort();
140
+ const apps: CatalogApp[] = [];
141
+ for (const appName of appNames) {
142
+ const appDir = path.join(versionDir, appName);
143
+ if (!isDir(appDir)) continue;
144
+ const app = loadApp(appDir, warnings);
145
+ if (app) apps.push(app);
146
+ }
147
+
148
+ // Try to enrich with the parent package.json if there is one (package > version layout).
149
+ const parentPkg = path.join(versionDir, "..", "package.json");
150
+ let pkgName: string | undefined;
151
+ let pkgDoc: Documentation | undefined;
152
+ if (exists(parentPkg)) {
153
+ const pkg = readJson<PackageJson>(parentPkg);
154
+ pkgName = pkg.name;
155
+ pkgDoc = pkg.docs;
156
+ }
157
+ pkgName ??= path.basename(path.dirname(versionDir));
158
+
159
+ return {
160
+ meta: {
161
+ name: pkgName,
162
+ version: version.name,
163
+ docker: version.container,
164
+ doc: pkgDoc,
165
+ },
166
+ apps,
167
+ };
168
+ }
169
+
170
+ function loadPackage(pkgDir: string, warnings?: string[]): CatalogPackage | null {
171
+ const pkgPath = path.join(pkgDir, "package.json");
172
+ if (!exists(pkgPath)) return null;
173
+ const pkg = readJson<PackageJson>(pkgPath);
174
+
175
+ const versionName = pkg.default ?? pkg.versions?.[0] ?? firstVersionDir(pkgDir);
176
+ if (!versionName) return null;
177
+ const versionDir = path.join(pkgDir, versionName);
178
+ if (!isDir(versionDir)) return null;
179
+
180
+ const loaded = loadVersion(versionDir, warnings);
181
+ if (!loaded) return null;
182
+
183
+ return {
184
+ meta: {
185
+ name: pkg.name ?? path.basename(pkgDir),
186
+ version: versionName,
187
+ docker: loaded.meta.docker,
188
+ doc: pkg.docs ?? loaded.meta.doc,
189
+ },
190
+ apps: loaded.apps,
191
+ };
192
+ }
193
+
194
+ function listSubdirs(dir: string): string[] {
195
+ return readdirSync(dir, { withFileTypes: true })
196
+ .filter((e) => e.isDirectory())
197
+ .map((e) => e.name);
198
+ }
199
+
200
+ function firstVersionDir(pkgDir: string): string | null {
201
+ for (const name of listSubdirs(pkgDir)) {
202
+ if (exists(path.join(pkgDir, name, "version.json"))) return name;
203
+ }
204
+ return null;
205
+ }
206
+
207
+ /**
208
+ * Load a catalog from any niwrap layout level. The level is auto-detected from
209
+ * which `*.json` index file is present in `root`. Always returns a normalized
210
+ * `project > package > app` shape, synthesizing missing layers as needed.
211
+ */
212
+ export function loadCatalog(root: string): CatalogProject {
213
+ const level = detectLevel(root);
214
+ if (!level) {
215
+ throw new Error(
216
+ `${root}: no project.json / package.json / version.json / app.json found - not a catalog root`,
217
+ );
218
+ }
219
+
220
+ const warnings: string[] = [];
221
+
222
+ if (level === "project") {
223
+ const project = readJson<ProjectJson>(path.join(root, "project.json"));
224
+ const packageNames = project.packages ?? listSubdirs(root);
225
+ const packages: CatalogPackage[] = [];
226
+ for (const name of packageNames) {
227
+ const pkgDir = path.join(root, name);
228
+ const pkg = loadPackage(pkgDir, warnings);
229
+ if (pkg) packages.push(pkg);
230
+ }
231
+ return {
232
+ meta: {
233
+ name: project.name,
234
+ version: project.version,
235
+ doc: project.docs,
236
+ license: project.license ? { description: project.license } : undefined,
237
+ },
238
+ packages,
239
+ warnings,
240
+ };
241
+ }
242
+
243
+ if (level === "package") {
244
+ const pkg = loadPackage(root, warnings);
245
+ if (!pkg) throw new Error(`${root}: package.json present but no resolvable version`);
246
+ return { meta: { name: pkg.meta.name }, packages: [pkg], warnings };
247
+ }
248
+
249
+ if (level === "version") {
250
+ const pkg = loadVersion(root, warnings);
251
+ if (!pkg) throw new Error(`${root}: version.json present but failed to load`);
252
+ return { meta: { name: pkg.meta.name }, packages: [pkg], warnings };
253
+ }
254
+
255
+ // level === "app": a direct single-app target, so a missing source is fatal.
256
+ const app = loadApp(root);
257
+ if (!app) throw new Error(`${root}: app.json present but missing source.path`);
258
+ return {
259
+ meta: {},
260
+ packages: [
261
+ {
262
+ meta: { name: path.basename(path.dirname(root)) },
263
+ apps: [app],
264
+ },
265
+ ],
266
+ warnings,
267
+ };
268
+ }
package/src/command.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { knownBackends, resolveBackends } from "./backends.js";
2
+ import { build, type BuildMode } from "./build.js";
3
+ import type { BuiltFile } from "./build.js";
4
+
5
+ export interface BuildFlags {
6
+ out?: string;
7
+ catalog?: string;
8
+ backend?: string | string[];
9
+ mode?: string;
10
+ }
11
+
12
+ export interface RunResult {
13
+ exitCode: number;
14
+ stdout: string[];
15
+ stderr: string[];
16
+ files: BuiltFile[];
17
+ }
18
+
19
+ /**
20
+ * Run the `build` command without side effects (no process.exit, no I/O).
21
+ * Returns the exit code, buffered output lines, and file map; the bin glue
22
+ * is responsible for actually writing files and forwarding output.
23
+ *
24
+ * Exit code split: 2 for CLI-shape errors (bad flag, unknown backend),
25
+ * 1 for build errors (parse failure, conflicting input/catalog), 0 for
26
+ * success.
27
+ */
28
+ export function runBuildCommand(input: string | undefined, flags: BuildFlags): RunResult {
29
+ const stdout: string[] = [];
30
+ const stderr: string[] = [];
31
+
32
+ const out = flags.out;
33
+ if (!out) {
34
+ stderr.push("error: -o/--out is required");
35
+ return { exitCode: 2, stdout, stderr, files: [] };
36
+ }
37
+
38
+ const backendNames = collectList(flags.backend);
39
+ const { backends, unknown } = resolveBackends(backendNames);
40
+ if (unknown.length > 0) {
41
+ stderr.push(`error: unknown backend(s): ${unknown.join(", ")}`);
42
+ stderr.push(`known: ${knownBackends.join(", ")}`);
43
+ return { exitCode: 2, stdout, stderr, files: [] };
44
+ }
45
+ if (backends.length === 0) {
46
+ stderr.push("error: no backends selected");
47
+ return { exitCode: 2, stdout, stderr, files: [] };
48
+ }
49
+
50
+ const mode = parseMode(flags.mode);
51
+ if (!mode) {
52
+ stderr.push(`error: --mode must be one of scripts | single | multi (got "${flags.mode}")`);
53
+ return { exitCode: 2, stdout, stderr, files: [] };
54
+ }
55
+
56
+ const result = build({ input, catalog: flags.catalog, out, backends, mode });
57
+
58
+ for (const w of result.warnings) stderr.push(`warn: ${w}`);
59
+ for (const e of result.errors) stderr.push(`error: ${e}`);
60
+
61
+ // Catalog builds report a tool tally so a few broken tools in a large catalog
62
+ // are visible at a glance rather than buried in per-tool error lines.
63
+ if (result.stats) {
64
+ const { appsCompiled, appsFailed, appsSkipped } = result.stats;
65
+ stderr.push(`summary: ${appsCompiled} compiled, ${appsFailed} failed, ${appsSkipped} skipped`);
66
+ }
67
+
68
+ if (result.errors.length > 0) {
69
+ return { exitCode: 1, stdout, stderr, files: [] };
70
+ }
71
+
72
+ stdout.push(`wrote ${result.files.length} file${result.files.length === 1 ? "" : "s"} to ${out}`);
73
+ return { exitCode: 0, stdout, stderr, files: result.files };
74
+ }
75
+
76
+ function collectList(value: string | string[] | undefined): string[] {
77
+ if (value === undefined) return [];
78
+ const arr = Array.isArray(value) ? value : [value];
79
+ return arr
80
+ .flatMap((v) => v.split(","))
81
+ .map((v) => v.trim())
82
+ .filter(Boolean);
83
+ }
84
+
85
+ function parseMode(value: string | undefined): BuildMode | null {
86
+ if (!value) return "scripts";
87
+ if (value === "scripts" || value === "single" || value === "multi") return value;
88
+ return null;
89
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export type { BuildMode, BuildOptions, BuildResult, BuiltFile } from "./build.js";
2
+ export { build } from "./build.js";
3
+ export type { CatalogApp, CatalogLevel, CatalogPackage, CatalogProject } from "./catalog.js";
4
+ export { detectLevel, loadCatalog } from "./catalog.js";
5
+ export { knownBackends, resolveBackends } from "./backends.js";
6
+ export { writeFiles } from "./write.js";
package/src/write.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ import type { BuiltFile } from "./build.js";
5
+
6
+ export function writeFiles(files: BuiltFile[]): void {
7
+ for (const file of files) {
8
+ mkdirSync(path.dirname(file.path), { recursive: true });
9
+ writeFileSync(file.path, file.content, "utf8");
10
+ }
11
+ }