@rigkit/cli 0.2.7 → 0.2.8

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.
@@ -5,17 +5,17 @@ import { join } from "node:path";
5
5
  import { discoverProjectConfigs, resolveConfigPaths } from "./project.ts";
6
6
 
7
7
  describe("CLI project resolution", () => {
8
- test("resolves -C to that directory's rig.config.ts", () => {
8
+ test("resolves -chdir to that directory's rig.config.ts", () => {
9
9
  const cwd = mkdtempSync(join(tmpdir(), "rigkit-cli-"));
10
10
  mkdirSync(join(cwd, "example"));
11
11
  writeFileSync(join(cwd, "example", "rig.config.ts"), "export default {}\n");
12
- const paths = resolveConfigPaths({ cwd, project: "example" });
12
+ const paths = resolveConfigPaths({ cwd, chdir: "example" });
13
13
 
14
14
  expect(paths.projectDir).toBe(join(cwd, "example"));
15
15
  expect(paths.configPath).toBe(join(cwd, "example", "rig.config.ts"));
16
16
  });
17
17
 
18
- test("resolves --config project root from the config dirname", () => {
18
+ test("resolves -config project root from the config dirname", () => {
19
19
  const cwd = mkdtempSync(join(tmpdir(), "rigkit-cli-"));
20
20
  const paths = resolveConfigPaths({ cwd, config: "machines/platform.ts" });
21
21
 
@@ -34,6 +34,16 @@ describe("CLI project resolution", () => {
34
34
  expect(paths.configPath).toBe(join(cwd, "project", "rig.config.ts"));
35
35
  });
36
36
 
37
+ test("reports named configs when the default config is missing", () => {
38
+ const cwd = mkdtempSync(join(tmpdir(), "rigkit-cli-"));
39
+ writeFileSync(join(cwd, "api.rig.config.ts"), "export default {}\n");
40
+ writeFileSync(join(cwd, "web.rig.config.ts"), "export default {}\n");
41
+
42
+ expect(() => resolveConfigPaths({ cwd })).toThrow(
43
+ /Found named Rigkit configs[\s\S]*api\.rig\.config\.ts[\s\S]*web\.rig\.config\.ts[\s\S]*rig -chdir=\. -config=api\.rig\.config\.ts <command>/,
44
+ );
45
+ });
46
+
37
47
  test("discovers projects downward without entering dependency directories", () => {
38
48
  const cwd = mkdtempSync(join(tmpdir(), "rigkit-cli-"));
39
49
  mkdirSync(join(cwd, "api"), { recursive: true });
@@ -48,4 +58,24 @@ describe("CLI project resolution", () => {
48
58
  configPath: join(cwd, "api", "rig.config.ts"),
49
59
  }]);
50
60
  });
61
+
62
+ test("discovers named configs downward", () => {
63
+ const cwd = mkdtempSync(join(tmpdir(), "rigkit-cli-"));
64
+ mkdirSync(join(cwd, "global-fragments"), { recursive: true });
65
+ writeFileSync(join(cwd, "global-fragments", "api.rig.config.ts"), "export default {}\n");
66
+ writeFileSync(join(cwd, "global-fragments", "web.rig.config.ts"), "export default {}\n");
67
+
68
+ const projects = discoverProjectConfigs({ cwd });
69
+
70
+ expect(projects).toEqual([
71
+ {
72
+ projectDir: join(cwd, "global-fragments"),
73
+ configPath: join(cwd, "global-fragments", "api.rig.config.ts"),
74
+ },
75
+ {
76
+ projectDir: join(cwd, "global-fragments"),
77
+ configPath: join(cwd, "global-fragments", "web.rig.config.ts"),
78
+ },
79
+ ]);
80
+ });
51
81
  });
package/src/project.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { dirname, join, resolve } from "node:path";
1
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
2
2
  import { existsSync, readdirSync } from "node:fs";
3
3
 
4
4
  export const DEFAULT_CONFIG_FILE = "rig.config.ts";
@@ -8,7 +8,7 @@ export const FREESTYLE_SDK_PACKAGE_NAME = "freestyle";
8
8
  export const FREESTYLE_SDK_PACKAGE_VERSION = "^0.1.51";
9
9
 
10
10
  export type ConfigPathOptions = {
11
- project?: string;
11
+ chdir?: string;
12
12
  config?: string;
13
13
  cwd?: string;
14
14
  };
@@ -22,20 +22,23 @@ export type DiscoveredProject = ResolvedConfigPaths;
22
22
 
23
23
  export function resolveConfigPaths(options: ConfigPathOptions): ResolvedConfigPaths {
24
24
  const cwd = resolve(options.cwd ?? process.cwd());
25
+ const workingDir = options.chdir ? resolve(cwd, options.chdir) : cwd;
25
26
  if (options.config) {
26
- const projectBase = options.project ? resolve(cwd, options.project) : cwd;
27
- const configPath = resolve(projectBase, options.config);
27
+ const configPath = resolve(workingDir, options.config);
28
28
  return {
29
29
  projectDir: dirname(configPath),
30
30
  configPath,
31
31
  };
32
32
  }
33
33
 
34
- const projectDir = options.project ? resolve(cwd, options.project) : findNearestProjectDir(cwd);
34
+ const projectDir = options.chdir ? workingDir : findNearestProjectDir(workingDir);
35
35
  const configPath = join(projectDir, DEFAULT_CONFIG_FILE);
36
36
 
37
37
  if (!existsSync(configPath)) {
38
- throw new Error(`No Rigkit config found at ${configPath}. Run "rig init" or pass --config <file>.`);
38
+ throw new Error(formatConfigNotFoundAt(configPath, {
39
+ commandCwd: cwd,
40
+ hint: namedRigConfigFilesHint(projectDir),
41
+ }));
39
42
  }
40
43
 
41
44
  return {
@@ -48,7 +51,7 @@ export function discoverProjectConfigs(options: ConfigPathOptions = {}): Discove
48
51
  if (options.config) return [resolveConfigPaths(options)];
49
52
 
50
53
  const cwd = resolve(options.cwd ?? process.cwd());
51
- const root = resolve(cwd, options.project ?? ".");
54
+ const root = resolve(cwd, options.chdir ?? ".");
52
55
  const projects: DiscoveredProject[] = [];
53
56
  visitProjectDirs(root, projects);
54
57
  return projects.sort((left, right) => left.configPath.localeCompare(right.configPath));
@@ -56,20 +59,24 @@ export function discoverProjectConfigs(options: ConfigPathOptions = {}): Discove
56
59
 
57
60
  function findNearestProjectDir(start: string): string {
58
61
  let current = start;
62
+ let hint: ConfigFilesHint | undefined;
59
63
  for (;;) {
60
64
  if (existsSync(join(current, DEFAULT_CONFIG_FILE))) return current;
65
+ hint ??= namedRigConfigFilesHint(current);
61
66
  const parent = dirname(current);
62
67
  if (parent === current) {
63
- throw new Error(`No Rigkit config found from ${start} upward. Run "rig init" or pass --config <file>.`);
68
+ throw new Error(formatConfigNotFoundFrom(start, { commandCwd: start, hint }));
64
69
  }
65
70
  current = parent;
66
71
  }
67
72
  }
68
73
 
69
74
  function visitProjectDirs(dir: string, projects: DiscoveredProject[]): void {
70
- const configPath = join(dir, DEFAULT_CONFIG_FILE);
71
- if (existsSync(configPath)) {
72
- projects.push({ projectDir: dir, configPath });
75
+ const configFiles = rigConfigFilesInDir(dir);
76
+ if (configFiles.length > 0) {
77
+ for (const configFile of configFiles) {
78
+ projects.push({ projectDir: dir, configPath: join(dir, configFile) });
79
+ }
73
80
  return;
74
81
  }
75
82
 
@@ -94,3 +101,84 @@ function shouldSkipDiscoveryDir(name: string): boolean {
94
101
  name === "dist" ||
95
102
  name === "build";
96
103
  }
104
+
105
+ export function rigConfigFilesInDir(dir: string): string[] {
106
+ let entries;
107
+ try {
108
+ entries = readdirSync(dir, { withFileTypes: true });
109
+ } catch {
110
+ return [];
111
+ }
112
+
113
+ return entries
114
+ .filter((entry) => entry.isFile() && isRigConfigFileName(entry.name))
115
+ .map((entry) => entry.name)
116
+ .sort((left, right) => {
117
+ if (left === DEFAULT_CONFIG_FILE) return -1;
118
+ if (right === DEFAULT_CONFIG_FILE) return 1;
119
+ return left.localeCompare(right);
120
+ });
121
+ }
122
+
123
+ export function isRigConfigFileName(name: string): boolean {
124
+ return name === DEFAULT_CONFIG_FILE || name.endsWith(".rig.config.ts");
125
+ }
126
+
127
+ type ConfigFilesHint = {
128
+ dir: string;
129
+ files: string[];
130
+ };
131
+
132
+ function namedRigConfigFilesHint(dir: string): ConfigFilesHint | undefined {
133
+ const files = rigConfigFilesInDir(dir).filter((file) => file !== DEFAULT_CONFIG_FILE);
134
+ return files.length > 0 ? { dir, files } : undefined;
135
+ }
136
+
137
+ function formatConfigNotFoundAt(
138
+ configPath: string,
139
+ options: { commandCwd: string; hint?: ConfigFilesHint },
140
+ ): string {
141
+ return appendConfigFilesHint(
142
+ `No Rigkit config found at ${configPath}.`,
143
+ options,
144
+ );
145
+ }
146
+
147
+ function formatConfigNotFoundFrom(
148
+ start: string,
149
+ options: { commandCwd: string; hint?: ConfigFilesHint },
150
+ ): string {
151
+ return appendConfigFilesHint(
152
+ `No Rigkit config found from ${start} upward.`,
153
+ options,
154
+ );
155
+ }
156
+
157
+ function appendConfigFilesHint(
158
+ message: string,
159
+ options: { commandCwd: string; hint?: ConfigFilesHint },
160
+ ): string {
161
+ const hint = options.hint;
162
+ if (!hint) return `${message} Run "rig init" or pass -config=<file>.`;
163
+
164
+ const configFile = hint.files[0]!;
165
+ const configPath = displayPath(options.commandCwd, join(hint.dir, configFile));
166
+ const projectDir = displayPath(options.commandCwd, hint.dir);
167
+
168
+ return [
169
+ message,
170
+ `Found named Rigkit configs in ${hint.dir}:`,
171
+ ...hint.files.map((file) => `- ${file}`),
172
+ "",
173
+ "Choose one explicitly:",
174
+ ` rig -config=${configPath} <command>`,
175
+ ` rig -chdir=${projectDir} -config=${configFile} <command>`,
176
+ ].join("\n");
177
+ }
178
+
179
+ function displayPath(from: string, path: string): string {
180
+ const relativePath = relative(from, path);
181
+ if (!relativePath) return ".";
182
+ if (!relativePath.startsWith("..") && !isAbsolute(relativePath)) return relativePath;
183
+ return path;
184
+ }
@@ -0,0 +1,92 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { appendFileSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { createRunLogger } from "./run-logger.ts";
6
+
7
+ describe("createRunLogger", () => {
8
+ test("writes a run.start envelope, every appended event, and a run.end envelope", () => {
9
+ const projectDir = mkdtempSync(join(tmpdir(), "rigkit-run-logger-"));
10
+ try {
11
+ const logger = createRunLogger({ projectDir, operation: "plan" });
12
+ expect(logger).toBeDefined();
13
+ logger!.append({ type: "node.started", nodePath: "build" });
14
+ logger!.append({ type: "node.completed", nodePath: "build" });
15
+ logger!.finish({ status: "completed", result: { ok: true } });
16
+ logger!.close();
17
+
18
+ const lines = readFileSync(logger!.path, "utf8").trim().split("\n");
19
+ expect(lines.length).toBe(4);
20
+ const entries = lines.map((line) => JSON.parse(line));
21
+ expect(entries[0]).toMatchObject({ type: "run.start", operation: "plan" });
22
+ expect(entries[1]).toMatchObject({ type: "node.started", nodePath: "build" });
23
+ expect(entries[2]).toMatchObject({ type: "node.completed", nodePath: "build" });
24
+ expect(entries[3]).toMatchObject({ type: "run.end", status: "completed", result: { ok: true } });
25
+
26
+ const logFiles = readdirSync(join(projectDir, ".rigkit", "logs"));
27
+ expect(logFiles.length).toBe(1);
28
+ } finally {
29
+ rmSync(projectDir, { recursive: true, force: true });
30
+ }
31
+ });
32
+
33
+ test("captures failure details on the run.end envelope", () => {
34
+ const projectDir = mkdtempSync(join(tmpdir(), "rigkit-run-logger-"));
35
+ try {
36
+ const logger = createRunLogger({ projectDir, operation: "apply" });
37
+ logger!.append({ type: "run.failed", error: { message: "boom" } });
38
+ logger!.finish({ status: "failed", error: new Error("boom") });
39
+ logger!.close();
40
+
41
+ const entries = readFileSync(logger!.path, "utf8").trim().split("\n").map((line) => JSON.parse(line));
42
+ expect(entries.at(-1)).toMatchObject({
43
+ type: "run.end",
44
+ status: "failed",
45
+ error: { message: "boom" },
46
+ });
47
+ } finally {
48
+ rmSync(projectDir, { recursive: true, force: true });
49
+ }
50
+ });
51
+
52
+ test("splices daemon stderr written during the run into the failure log", () => {
53
+ const projectDir = mkdtempSync(join(tmpdir(), "rigkit-run-logger-"));
54
+ const daemonStderrPath = join(projectDir, "daemon.log");
55
+ // Pre-existing daemon output that predates the run — we must NOT splice it.
56
+ writeFileSync(daemonStderrPath, "earlier daemon noise we should ignore\n");
57
+
58
+ try {
59
+ const logger = createRunLogger({ projectDir, operation: "apply", daemonStderrPath });
60
+ // Daemon writes a real stack trace mid-run.
61
+ appendFileSync(daemonStderrPath, "Error: connect ECONNREFUSED 127.0.0.1:443\n at fetch (engine.ts:42)\n");
62
+ logger!.finish({ status: "failed", error: new Error("INTERNAL_ERROR: Internal server error") });
63
+ logger!.close();
64
+
65
+ const entries = readFileSync(logger!.path, "utf8").trim().split("\n").map((line) => JSON.parse(line));
66
+ const stderr = entries.filter((entry) => entry.type === "daemon.stderr").map((entry) => entry.data);
67
+ expect(stderr).toEqual([
68
+ "Error: connect ECONNREFUSED 127.0.0.1:443",
69
+ " at fetch (engine.ts:42)",
70
+ ]);
71
+ expect(stderr.some((line) => line.includes("earlier daemon noise"))).toBe(false);
72
+ } finally {
73
+ rmSync(projectDir, { recursive: true, force: true });
74
+ }
75
+ });
76
+
77
+ test("does not splice daemon stderr on success", () => {
78
+ const projectDir = mkdtempSync(join(tmpdir(), "rigkit-run-logger-"));
79
+ const daemonStderrPath = join(projectDir, "daemon.log");
80
+ try {
81
+ const logger = createRunLogger({ projectDir, operation: "apply", daemonStderrPath });
82
+ appendFileSync(daemonStderrPath, "noise during a successful run\n");
83
+ logger!.finish({ status: "completed" });
84
+ logger!.close();
85
+
86
+ const entries = readFileSync(logger!.path, "utf8").trim().split("\n").map((line) => JSON.parse(line));
87
+ expect(entries.find((entry) => entry.type === "daemon.stderr")).toBeUndefined();
88
+ } finally {
89
+ rmSync(projectDir, { recursive: true, force: true });
90
+ }
91
+ });
92
+ });
@@ -0,0 +1,203 @@
1
+ // Persists every runtime event for a single run to `.rigkit/logs/`. The
2
+ // presenter on stderr stays terse; this file is the unfiltered transcript you
3
+ // grep when something goes wrong. NDJSON, one event per line, plus a couple of
4
+ // envelope records (run.start / run.end) so logs stand on their own.
5
+ //
6
+ // On failure we also splice in the daemon's stderr emitted during this run's
7
+ // time window. The daemon's "INTERNAL_ERROR: Internal server error" event
8
+ // carries no stack — the real trace is in its stderr. We capture that to
9
+ // `runtimeLogPath` (in runtime-client) and tail it here so the run log is
10
+ // self-contained.
11
+
12
+ import {
13
+ appendFileSync,
14
+ closeSync,
15
+ mkdirSync,
16
+ openSync,
17
+ readSync,
18
+ readdirSync,
19
+ statSync,
20
+ unlinkSync,
21
+ } from "node:fs";
22
+ import { join } from "node:path";
23
+
24
+ const LOG_DIR_NAME = "logs";
25
+ const MAX_LOG_FILES = 50;
26
+
27
+ export type RunLogger = {
28
+ append(event: unknown): void;
29
+ finish(outcome: { status: "completed" | "failed"; error?: unknown; result?: unknown }): void;
30
+ close(): void;
31
+ path: string;
32
+ };
33
+
34
+ export function createRunLogger(input: {
35
+ projectDir: string;
36
+ operation: string;
37
+ runtimeStateDir?: string;
38
+ // Daemon stderr file. When the run fails we splice everything written here
39
+ // during this run's time window into the run log.
40
+ daemonStderrPath?: string;
41
+ }): RunLogger | undefined {
42
+ const logDir = resolveLogDir(input);
43
+ if (!logDir) return undefined;
44
+
45
+ try {
46
+ mkdirSync(logDir, { recursive: true });
47
+ } catch {
48
+ return undefined;
49
+ }
50
+
51
+ const path = join(logDir, `${fileTimestamp()}-${slugify(input.operation)}.log`);
52
+
53
+ let fd: number;
54
+ try {
55
+ fd = openSync(path, "a");
56
+ } catch {
57
+ return undefined;
58
+ }
59
+
60
+ rotate(logDir);
61
+
62
+ const startedAt = new Date().toISOString();
63
+ // Snapshot the daemon log's current size so we can read only what this run
64
+ // appended later. We don't want to dump the daemon's entire history.
65
+ const daemonOffset = input.daemonStderrPath ? safeFileSize(input.daemonStderrPath) : 0;
66
+ let closed = false;
67
+
68
+ const writeLine = (value: unknown): void => {
69
+ if (closed) return;
70
+ try {
71
+ appendFileSync(fd, `${JSON.stringify(value)}\n`);
72
+ } catch {
73
+ // best-effort: a logging failure should never break the run
74
+ }
75
+ };
76
+
77
+ writeLine({
78
+ ts: startedAt,
79
+ type: "run.start",
80
+ operation: input.operation,
81
+ cwd: process.cwd(),
82
+ });
83
+
84
+ return {
85
+ path,
86
+ append(event) {
87
+ writeLine({ ts: new Date().toISOString(), ...(toRecord(event) ?? { value: event }) });
88
+ },
89
+ finish(outcome) {
90
+ if (outcome.status === "failed" && input.daemonStderrPath) {
91
+ spliceDaemonStderr(input.daemonStderrPath, daemonOffset, writeLine);
92
+ }
93
+ writeLine({
94
+ ts: new Date().toISOString(),
95
+ type: "run.end",
96
+ status: outcome.status,
97
+ durationMs: Date.now() - Date.parse(startedAt),
98
+ ...(outcome.error !== undefined ? { error: toErrorRecord(outcome.error) } : {}),
99
+ ...(outcome.result !== undefined ? { result: outcome.result } : {}),
100
+ });
101
+ },
102
+ close() {
103
+ if (closed) return;
104
+ closed = true;
105
+ try {
106
+ closeSync(fd);
107
+ } catch {
108
+ // best-effort
109
+ }
110
+ },
111
+ };
112
+ }
113
+
114
+ function spliceDaemonStderr(
115
+ path: string,
116
+ offset: number,
117
+ write: (value: unknown) => void,
118
+ ): void {
119
+ let fd: number | undefined;
120
+ try {
121
+ const size = safeFileSize(path);
122
+ if (size <= offset) return;
123
+ fd = openSync(path, "r");
124
+ const length = size - offset;
125
+ const buffer = Buffer.alloc(length);
126
+ readSync(fd, buffer, 0, length, offset);
127
+ const text = buffer.toString("utf8");
128
+ for (const line of text.split("\n")) {
129
+ if (!line) continue;
130
+ write({ ts: new Date().toISOString(), type: "daemon.stderr", data: line });
131
+ }
132
+ } catch {
133
+ // best-effort: failure to splice should never break the run log
134
+ } finally {
135
+ if (fd !== undefined) {
136
+ try {
137
+ closeSync(fd);
138
+ } catch {
139
+ // best-effort
140
+ }
141
+ }
142
+ }
143
+ }
144
+
145
+ function safeFileSize(path: string): number {
146
+ try {
147
+ return statSync(path).size;
148
+ } catch {
149
+ return 0;
150
+ }
151
+ }
152
+
153
+ function resolveLogDir(input: { projectDir: string; runtimeStateDir?: string }): string | undefined {
154
+ if (input.runtimeStateDir) return join(input.runtimeStateDir, LOG_DIR_NAME);
155
+ if (input.projectDir) return join(input.projectDir, ".rigkit", LOG_DIR_NAME);
156
+ return undefined;
157
+ }
158
+
159
+ function rotate(logDir: string): void {
160
+ try {
161
+ const entries = readdirSync(logDir)
162
+ .filter((name) => name.endsWith(".log"))
163
+ .map((name) => {
164
+ const filePath = join(logDir, name);
165
+ return { path: filePath, mtime: statSync(filePath).mtimeMs };
166
+ })
167
+ .sort((a, b) => b.mtime - a.mtime);
168
+
169
+ for (const stale of entries.slice(MAX_LOG_FILES)) {
170
+ try {
171
+ unlinkSync(stale.path);
172
+ } catch {
173
+ // best-effort
174
+ }
175
+ }
176
+ } catch {
177
+ // best-effort
178
+ }
179
+ }
180
+
181
+ function fileTimestamp(): string {
182
+ return new Date().toISOString().replace(/[:.]/g, "-");
183
+ }
184
+
185
+ function slugify(value: string): string {
186
+ const cleaned = value.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
187
+ const slug = cleaned.slice(0, 40);
188
+ return slug || "run";
189
+ }
190
+
191
+ function toRecord(value: unknown): Record<string, unknown> | undefined {
192
+ return value && typeof value === "object" && !Array.isArray(value)
193
+ ? (value as Record<string, unknown>)
194
+ : undefined;
195
+ }
196
+
197
+ function toErrorRecord(value: unknown): Record<string, unknown> {
198
+ if (value instanceof Error) {
199
+ return { name: value.name, message: value.message, stack: value.stack };
200
+ }
201
+ const record = toRecord(value);
202
+ return record ?? { message: String(value) };
203
+ }