@rigkit/cli 0.0.0-canary-20260518T014918-c5bc0c2
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 +34 -0
- package/package.json +42 -0
- package/src/cli.test.ts +419 -0
- package/src/cli.ts +2496 -0
- package/src/completion.test.ts +413 -0
- package/src/completion.ts +844 -0
- package/src/init.test.ts +90 -0
- package/src/init.ts +269 -0
- package/src/interaction.test.ts +28 -0
- package/src/interaction.ts +33 -0
- package/src/project.test.ts +81 -0
- package/src/project.ts +184 -0
- package/src/run-logger.test.ts +92 -0
- package/src/run-logger.ts +203 -0
- package/src/run-presenter.ts +250 -0
- package/src/ui.ts +159 -0
- package/src/version.ts +1 -0
- package/src/workspace-name.test.ts +17 -0
- package/src/workspace-name.ts +59 -0
package/src/init.test.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { initProject, normalizeMachineName } from "./init.ts";
|
|
6
|
+
import {
|
|
7
|
+
FREESTYLE_PROVIDER_PACKAGE_NAME,
|
|
8
|
+
FREESTYLE_SDK_PACKAGE_NAME,
|
|
9
|
+
FREESTYLE_SDK_PACKAGE_VERSION,
|
|
10
|
+
PROJECT_PACKAGE_NAME,
|
|
11
|
+
} from "./project.ts";
|
|
12
|
+
import { RIGKIT_CLI_VERSION } from "./version.ts";
|
|
13
|
+
|
|
14
|
+
describe("initProject", () => {
|
|
15
|
+
test("creates a full Rigkit project", () => {
|
|
16
|
+
const parentDir = mkdtempSync(join(tmpdir(), "rigkit-init-"));
|
|
17
|
+
const projectDir = join(parentDir, "platform-api");
|
|
18
|
+
const result = initProject({
|
|
19
|
+
projectDir,
|
|
20
|
+
configPath: join(projectDir, "rig.config.ts"),
|
|
21
|
+
name: "Platform API",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
expect(result.name).toBe("platform-api");
|
|
25
|
+
expect(result.projectDir).toBe(projectDir);
|
|
26
|
+
expect(existsSync(projectDir)).toBe(true);
|
|
27
|
+
expect(result.created).toEqual({
|
|
28
|
+
config: true,
|
|
29
|
+
env: false,
|
|
30
|
+
envExample: false,
|
|
31
|
+
gitignore: true,
|
|
32
|
+
packageJson: true,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
expect(readFileSync(join(projectDir, "rig.config.ts"), "utf8")).toContain('sequence("platform-api"');
|
|
36
|
+
expect(readFileSync(join(projectDir, "rig.config.ts"), "utf8")).toContain("defineConfig({");
|
|
37
|
+
expect(readFileSync(join(projectDir, "rig.config.ts"), "utf8")).toContain("new VmSpec()");
|
|
38
|
+
expect(readFileSync(join(projectDir, "rig.config.ts"), "utf8")).not.toContain("FREESTYLE_API_KEY");
|
|
39
|
+
expect(existsSync(join(projectDir, ".env"))).toBe(false);
|
|
40
|
+
expect(existsSync(join(projectDir, ".env.example"))).toBe(false);
|
|
41
|
+
expect(readFileSync(join(projectDir, ".gitignore"), "utf8")).toContain(".env\n.rigkit/\n");
|
|
42
|
+
|
|
43
|
+
const pkg = JSON.parse(readFileSync(join(projectDir, "package.json"), "utf8"));
|
|
44
|
+
expect(pkg.name).toBe("platform-api");
|
|
45
|
+
expect(pkg.scripts.plan).toBe("rig plan");
|
|
46
|
+
expect(pkg.scripts.apply).toBe("rig apply");
|
|
47
|
+
expect(pkg.devDependencies[PROJECT_PACKAGE_NAME]).toBe(RIGKIT_CLI_VERSION);
|
|
48
|
+
expect(pkg.devDependencies[FREESTYLE_PROVIDER_PACKAGE_NAME]).toBe(RIGKIT_CLI_VERSION);
|
|
49
|
+
expect(pkg.devDependencies[FREESTYLE_SDK_PACKAGE_NAME]).toBe(FREESTYLE_SDK_PACKAGE_VERSION);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("updates existing project files without replacing package metadata", () => {
|
|
53
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-init-"));
|
|
54
|
+
mkdirSync(projectDir, { recursive: true });
|
|
55
|
+
writeFileSync(join(projectDir, ".env"), "OTHER=value\nFREESTYLE_API_KEY=old\n");
|
|
56
|
+
writeFileSync(join(projectDir, ".gitignore"), "node_modules/\n");
|
|
57
|
+
writeFileSync(
|
|
58
|
+
join(projectDir, "package.json"),
|
|
59
|
+
`${JSON.stringify({ name: "existing", scripts: { test: "bun test" } }, null, 2)}\n`,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const result = initProject({
|
|
63
|
+
projectDir,
|
|
64
|
+
configPath: join(projectDir, "rig.config.ts"),
|
|
65
|
+
name: "dev",
|
|
66
|
+
apiKey: "new-key",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(result.created.packageJson).toBe(false);
|
|
70
|
+
expect(result.updated.packageJson).toBe(true);
|
|
71
|
+
expect(readFileSync(join(projectDir, ".env"), "utf8")).toBe("OTHER=value\nFREESTYLE_API_KEY=new-key\n");
|
|
72
|
+
expect(readFileSync(join(projectDir, ".gitignore"), "utf8")).toBe("node_modules/\n.env\n.rigkit/\n");
|
|
73
|
+
|
|
74
|
+
const pkg = JSON.parse(readFileSync(join(projectDir, "package.json"), "utf8"));
|
|
75
|
+
expect(pkg.name).toBe("existing");
|
|
76
|
+
expect(pkg.scripts.test).toBe("bun test");
|
|
77
|
+
expect(pkg.scripts.plan).toBe("rig plan");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("normalizeMachineName", () => {
|
|
82
|
+
test("normalizes human names into machine names", () => {
|
|
83
|
+
expect(normalizeMachineName(" My Platform API ")).toBe("my-platform-api");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("rejects empty names", () => {
|
|
87
|
+
expect(() => normalizeMachineName(" ")).toThrow("Project name is required.");
|
|
88
|
+
expect(() => normalizeMachineName("!!!")).toThrow("Project name is required.");
|
|
89
|
+
});
|
|
90
|
+
});
|
package/src/init.ts
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { RIGKIT_CLI_VERSION } from "./version.ts";
|
|
4
|
+
import {
|
|
5
|
+
FREESTYLE_PROVIDER_PACKAGE_NAME,
|
|
6
|
+
FREESTYLE_SDK_PACKAGE_NAME,
|
|
7
|
+
FREESTYLE_SDK_PACKAGE_VERSION,
|
|
8
|
+
PROJECT_PACKAGE_NAME,
|
|
9
|
+
} from "./project.ts";
|
|
10
|
+
|
|
11
|
+
export type InitProjectInput = {
|
|
12
|
+
projectDir: string;
|
|
13
|
+
configPath: string;
|
|
14
|
+
name: string;
|
|
15
|
+
apiKey?: string;
|
|
16
|
+
force?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type InitProjectResult = {
|
|
20
|
+
name: string;
|
|
21
|
+
projectDir: string;
|
|
22
|
+
configPath: string;
|
|
23
|
+
envPath: string;
|
|
24
|
+
envExamplePath: string;
|
|
25
|
+
gitignorePath: string;
|
|
26
|
+
packageJsonPath: string;
|
|
27
|
+
created: {
|
|
28
|
+
config: boolean;
|
|
29
|
+
env: boolean;
|
|
30
|
+
envExample: boolean;
|
|
31
|
+
packageJson: boolean;
|
|
32
|
+
gitignore: boolean;
|
|
33
|
+
};
|
|
34
|
+
updated: {
|
|
35
|
+
envApiKey: boolean;
|
|
36
|
+
gitignore: boolean;
|
|
37
|
+
packageJson: boolean;
|
|
38
|
+
sdkDependency: boolean;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function initProject(input: InitProjectInput): InitProjectResult {
|
|
43
|
+
const name = normalizeMachineName(input.name);
|
|
44
|
+
mkdirSync(input.projectDir, { recursive: true });
|
|
45
|
+
|
|
46
|
+
if (existsSync(input.configPath) && !input.force) {
|
|
47
|
+
throw new Error(`${input.configPath} already exists. Pass --force to overwrite it.`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const wroteConfig = !existsSync(input.configPath) || Boolean(input.force);
|
|
51
|
+
if (wroteConfig) {
|
|
52
|
+
writeFileSync(input.configPath, starterConfig(name));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const apiKey = input.apiKey?.trim();
|
|
56
|
+
const envPath = join(input.projectDir, ".env");
|
|
57
|
+
const env = apiKey
|
|
58
|
+
? writeEnvFile(envPath, apiKey)
|
|
59
|
+
: { created: false, updated: false };
|
|
60
|
+
|
|
61
|
+
const envExamplePath = join(input.projectDir, ".env.example");
|
|
62
|
+
const wroteEnvExample = Boolean(apiKey) && !existsSync(envExamplePath);
|
|
63
|
+
if (wroteEnvExample) {
|
|
64
|
+
writeFileSync(envExamplePath, "FREESTYLE_API_KEY=\n");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const gitignore = ensureGitignore(input.projectDir);
|
|
68
|
+
const packageJson = ensureProjectPackageJson(input.projectDir, name);
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
name,
|
|
72
|
+
projectDir: input.projectDir,
|
|
73
|
+
configPath: input.configPath,
|
|
74
|
+
envPath,
|
|
75
|
+
envExamplePath,
|
|
76
|
+
gitignorePath: gitignore.path,
|
|
77
|
+
packageJsonPath: packageJson.path,
|
|
78
|
+
created: {
|
|
79
|
+
config: wroteConfig,
|
|
80
|
+
env: env.created,
|
|
81
|
+
envExample: wroteEnvExample,
|
|
82
|
+
gitignore: gitignore.created,
|
|
83
|
+
packageJson: packageJson.created,
|
|
84
|
+
},
|
|
85
|
+
updated: {
|
|
86
|
+
envApiKey: env.updated,
|
|
87
|
+
gitignore: gitignore.updated,
|
|
88
|
+
packageJson: packageJson.updated,
|
|
89
|
+
sdkDependency: packageJson.sdkDependencyChanged,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function normalizeMachineName(value: string): string {
|
|
95
|
+
const name = value.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
96
|
+
if (!name) {
|
|
97
|
+
throw new Error("Project name is required.");
|
|
98
|
+
}
|
|
99
|
+
return name;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function starterConfig(name: string): string {
|
|
103
|
+
const workflowName = JSON.stringify(normalizeMachineName(name));
|
|
104
|
+
|
|
105
|
+
return `import { defineConfig, sequence } from "@rigkit/sdk";
|
|
106
|
+
import { freestyle, VmBaseImage, VmSpec } from "@rigkit/provider-freestyle";
|
|
107
|
+
|
|
108
|
+
const vmIdleTimeoutSeconds = 3600;
|
|
109
|
+
const vmSpec = new VmSpec()
|
|
110
|
+
.baseImage(new VmBaseImage("FROM node:22"))
|
|
111
|
+
.idleTimeoutSeconds(vmIdleTimeoutSeconds);
|
|
112
|
+
|
|
113
|
+
const freestyleProvider = freestyle.provider();
|
|
114
|
+
|
|
115
|
+
const dev = sequence(${workflowName})
|
|
116
|
+
.step("verify-node-22", async ({ providers }) => {
|
|
117
|
+
console.log("creating verification vm");
|
|
118
|
+
const { vm, vmId } = await providers.freestyle.client.vms.create({
|
|
119
|
+
spec: vmSpec,
|
|
120
|
+
logger: console.log,
|
|
121
|
+
});
|
|
122
|
+
try {
|
|
123
|
+
const result = await vm.exec("node --version");
|
|
124
|
+
if ((result.statusCode ?? 0) !== 0 || !result.stdout.trim().startsWith("v22.")) {
|
|
125
|
+
throw new Error(\`Expected Node.js v22, got: \${result.stdout}\${result.stderr}\`);
|
|
126
|
+
}
|
|
127
|
+
const snapshot = await vm.snapshot();
|
|
128
|
+
return { ctx: { snapshotId: snapshot.snapshotId } };
|
|
129
|
+
} finally {
|
|
130
|
+
await providers.freestyle.client.vms.delete({ vmId });
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
.workspace({
|
|
134
|
+
create: async ({ workflow, providers }) => {
|
|
135
|
+
console.log("booting workspace vm");
|
|
136
|
+
const { vmId } = await providers.freestyle.client.vms.create({
|
|
137
|
+
snapshotId: workflow.ctx.snapshotId,
|
|
138
|
+
idleTimeoutSeconds: vmIdleTimeoutSeconds,
|
|
139
|
+
logger: console.log,
|
|
140
|
+
});
|
|
141
|
+
return {
|
|
142
|
+
vmId,
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
remove: async ({ providers, workspace }) => {
|
|
146
|
+
await providers.freestyle.client.vms.delete({ vmId: workspace.ctx.vmId });
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
export default defineConfig({
|
|
151
|
+
providers: {
|
|
152
|
+
freestyle: freestyleProvider,
|
|
153
|
+
},
|
|
154
|
+
workflows: {
|
|
155
|
+
dev,
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function writeEnvFile(path: string, apiKey: string): { created: boolean; updated: boolean } {
|
|
162
|
+
const created = !existsSync(path);
|
|
163
|
+
const existing = created ? "" : readFileSync(path, "utf8");
|
|
164
|
+
const lines = existing ? existing.split(/\r?\n/) : [];
|
|
165
|
+
const nextLine = `FREESTYLE_API_KEY=${apiKey}`;
|
|
166
|
+
let found = false;
|
|
167
|
+
let updated = created;
|
|
168
|
+
|
|
169
|
+
const next = lines.map((line) => {
|
|
170
|
+
if (!line.startsWith("FREESTYLE_API_KEY=")) return line;
|
|
171
|
+
found = true;
|
|
172
|
+
if (line === nextLine) return line;
|
|
173
|
+
updated = true;
|
|
174
|
+
return nextLine;
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
if (!found) {
|
|
178
|
+
if (next.length > 0 && next[next.length - 1] !== "") next.push("");
|
|
179
|
+
next.push(nextLine);
|
|
180
|
+
updated = true;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (updated) {
|
|
184
|
+
writeFileSync(path, `${next.join("\n").replace(/\n+$/, "")}\n`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { created, updated };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function ensureGitignore(projectDir: string): { path: string; created: boolean; updated: boolean } {
|
|
191
|
+
const path = join(projectDir, ".gitignore");
|
|
192
|
+
const created = !existsSync(path);
|
|
193
|
+
const existing = created ? "" : readFileSync(path, "utf8");
|
|
194
|
+
const entries = existing.split(/\r?\n/).filter(Boolean);
|
|
195
|
+
let updated = false;
|
|
196
|
+
|
|
197
|
+
for (const entry of [".env", ".rigkit/"]) {
|
|
198
|
+
if (!entries.includes(entry)) {
|
|
199
|
+
entries.push(entry);
|
|
200
|
+
updated = true;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (created || updated) {
|
|
205
|
+
writeFileSync(path, `${entries.join("\n")}\n`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return { path, created, updated: created || updated };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function ensureProjectPackageJson(
|
|
212
|
+
projectDir: string,
|
|
213
|
+
name: string,
|
|
214
|
+
): { path: string; created: boolean; updated: boolean; sdkDependencyChanged: boolean } {
|
|
215
|
+
const path = join(projectDir, "package.json");
|
|
216
|
+
const created = !existsSync(path);
|
|
217
|
+
const pkg = created
|
|
218
|
+
? {
|
|
219
|
+
name,
|
|
220
|
+
private: true,
|
|
221
|
+
type: "module",
|
|
222
|
+
scripts: {},
|
|
223
|
+
}
|
|
224
|
+
: JSON.parse(readFileSync(path, "utf8")) as Record<string, any>;
|
|
225
|
+
|
|
226
|
+
let updated = created;
|
|
227
|
+
|
|
228
|
+
if (!isRecord(pkg.scripts)) {
|
|
229
|
+
pkg.scripts = {};
|
|
230
|
+
updated = true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const scripts = pkg.scripts as Record<string, string>;
|
|
234
|
+
for (const [key, value] of Object.entries({ plan: "rig plan", apply: "rig apply" })) {
|
|
235
|
+
if (scripts[key] !== value) {
|
|
236
|
+
scripts[key] = value;
|
|
237
|
+
updated = true;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
pkg.scripts = sortObject(scripts);
|
|
241
|
+
|
|
242
|
+
const devDependencies = isRecord(pkg.devDependencies) ? pkg.devDependencies : {};
|
|
243
|
+
const sdkDependencyChanged =
|
|
244
|
+
devDependencies[PROJECT_PACKAGE_NAME] !== RIGKIT_CLI_VERSION ||
|
|
245
|
+
devDependencies[FREESTYLE_PROVIDER_PACKAGE_NAME] !== RIGKIT_CLI_VERSION ||
|
|
246
|
+
devDependencies[FREESTYLE_SDK_PACKAGE_NAME] !== FREESTYLE_SDK_PACKAGE_VERSION;
|
|
247
|
+
if (sdkDependencyChanged) {
|
|
248
|
+
delete devDependencies["@rigkit/runtime"];
|
|
249
|
+
devDependencies[PROJECT_PACKAGE_NAME] = RIGKIT_CLI_VERSION;
|
|
250
|
+
devDependencies[FREESTYLE_PROVIDER_PACKAGE_NAME] = RIGKIT_CLI_VERSION;
|
|
251
|
+
devDependencies[FREESTYLE_SDK_PACKAGE_NAME] = FREESTYLE_SDK_PACKAGE_VERSION;
|
|
252
|
+
updated = true;
|
|
253
|
+
}
|
|
254
|
+
pkg.devDependencies = sortObject(devDependencies);
|
|
255
|
+
|
|
256
|
+
if (created || updated) {
|
|
257
|
+
writeFileSync(path, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return { path, created, updated, sdkDependencyChanged };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function isRecord(value: unknown): value is Record<string, any> {
|
|
264
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function sortObject<T>(value: Record<string, T>): Record<string, T> {
|
|
268
|
+
return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)));
|
|
269
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createLocalInteractionPresenter } from "./interaction.ts";
|
|
3
|
+
|
|
4
|
+
describe("local interaction presenter", () => {
|
|
5
|
+
test("presents provider-owned URLs without waiting for completion", async () => {
|
|
6
|
+
const previousNoBrowser = process.env.RIGKIT_NO_BROWSER;
|
|
7
|
+
process.env.RIGKIT_NO_BROWSER = "1";
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
const presenter = createLocalInteractionPresenter();
|
|
11
|
+
await presenter({
|
|
12
|
+
id: "interaction-1",
|
|
13
|
+
nodePath: "login",
|
|
14
|
+
title: "GitHub auth",
|
|
15
|
+
url: "http://127.0.0.1:1234/?token=test",
|
|
16
|
+
instructions: "Authenticate GitHub inside the VM.",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
expect(true).toBe(true);
|
|
20
|
+
} finally {
|
|
21
|
+
if (previousNoBrowser === undefined) {
|
|
22
|
+
delete process.env.RIGKIT_NO_BROWSER;
|
|
23
|
+
} else {
|
|
24
|
+
process.env.RIGKIT_NO_BROWSER = previousNoBrowser;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { InteractionPresenter } from "@rigkit/engine";
|
|
2
|
+
|
|
3
|
+
export function createLocalInteractionPresenter(): InteractionPresenter {
|
|
4
|
+
return async (request) => {
|
|
5
|
+
console.error(`\nInteractive task: ${request.title}`);
|
|
6
|
+
if (request.instructions) console.error(request.instructions);
|
|
7
|
+
console.error(`Open ${request.url}`);
|
|
8
|
+
|
|
9
|
+
openExternalTarget(request.url);
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function openExternalTarget(url: string): void {
|
|
14
|
+
if (process.env.RIGKIT_NO_BROWSER === "1") return;
|
|
15
|
+
|
|
16
|
+
const command =
|
|
17
|
+
process.platform === "darwin"
|
|
18
|
+
? ["open", url]
|
|
19
|
+
: process.platform === "win32"
|
|
20
|
+
? ["cmd", "/c", "start", "", url]
|
|
21
|
+
: ["xdg-open", url];
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const proc = Bun.spawn(command, {
|
|
25
|
+
stdin: "ignore",
|
|
26
|
+
stdout: "ignore",
|
|
27
|
+
stderr: "ignore",
|
|
28
|
+
});
|
|
29
|
+
proc.exited.catch(() => {});
|
|
30
|
+
} catch {
|
|
31
|
+
// The engine also emits the URL, so failed auto-open still leaves a manual path.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { discoverProjectConfigs, resolveConfigPaths } from "./project.ts";
|
|
6
|
+
|
|
7
|
+
describe("CLI project resolution", () => {
|
|
8
|
+
test("resolves -chdir to that directory's rig.config.ts", () => {
|
|
9
|
+
const cwd = mkdtempSync(join(tmpdir(), "rigkit-cli-"));
|
|
10
|
+
mkdirSync(join(cwd, "example"));
|
|
11
|
+
writeFileSync(join(cwd, "example", "rig.config.ts"), "export default {}\n");
|
|
12
|
+
const paths = resolveConfigPaths({ cwd, chdir: "example" });
|
|
13
|
+
|
|
14
|
+
expect(paths.projectDir).toBe(join(cwd, "example"));
|
|
15
|
+
expect(paths.configPath).toBe(join(cwd, "example", "rig.config.ts"));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("resolves -config project root from the config dirname", () => {
|
|
19
|
+
const cwd = mkdtempSync(join(tmpdir(), "rigkit-cli-"));
|
|
20
|
+
const paths = resolveConfigPaths({ cwd, config: "machines/platform.ts" });
|
|
21
|
+
|
|
22
|
+
expect(paths.projectDir).toBe(join(cwd, "machines"));
|
|
23
|
+
expect(paths.configPath).toBe(join(cwd, "machines", "platform.ts"));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("searches upward from cwd for the nearest config", () => {
|
|
27
|
+
const cwd = mkdtempSync(join(tmpdir(), "rigkit-cli-"));
|
|
28
|
+
mkdirSync(join(cwd, "project", "nested"), { recursive: true });
|
|
29
|
+
writeFileSync(join(cwd, "project", "rig.config.ts"), "export default {}\n");
|
|
30
|
+
|
|
31
|
+
const paths = resolveConfigPaths({ cwd: join(cwd, "project", "nested") });
|
|
32
|
+
|
|
33
|
+
expect(paths.projectDir).toBe(join(cwd, "project"));
|
|
34
|
+
expect(paths.configPath).toBe(join(cwd, "project", "rig.config.ts"));
|
|
35
|
+
});
|
|
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
|
+
|
|
47
|
+
test("discovers projects downward without entering dependency directories", () => {
|
|
48
|
+
const cwd = mkdtempSync(join(tmpdir(), "rigkit-cli-"));
|
|
49
|
+
mkdirSync(join(cwd, "api"), { recursive: true });
|
|
50
|
+
mkdirSync(join(cwd, "node_modules", "ignored"), { recursive: true });
|
|
51
|
+
writeFileSync(join(cwd, "api", "rig.config.ts"), "export default {}\n");
|
|
52
|
+
writeFileSync(join(cwd, "node_modules", "ignored", "rig.config.ts"), "export default {}\n");
|
|
53
|
+
|
|
54
|
+
const projects = discoverProjectConfigs({ cwd });
|
|
55
|
+
|
|
56
|
+
expect(projects).toEqual([{
|
|
57
|
+
projectDir: join(cwd, "api"),
|
|
58
|
+
configPath: join(cwd, "api", "rig.config.ts"),
|
|
59
|
+
}]);
|
|
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
|
+
});
|
|
81
|
+
});
|
package/src/project.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
2
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_CONFIG_FILE = "rig.config.ts";
|
|
5
|
+
export const PROJECT_PACKAGE_NAME = "@rigkit/sdk";
|
|
6
|
+
export const FREESTYLE_PROVIDER_PACKAGE_NAME = "@rigkit/provider-freestyle";
|
|
7
|
+
export const FREESTYLE_SDK_PACKAGE_NAME = "freestyle";
|
|
8
|
+
export const FREESTYLE_SDK_PACKAGE_VERSION = "^0.1.51";
|
|
9
|
+
|
|
10
|
+
export type ConfigPathOptions = {
|
|
11
|
+
chdir?: string;
|
|
12
|
+
config?: string;
|
|
13
|
+
cwd?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type ResolvedConfigPaths = {
|
|
17
|
+
projectDir: string;
|
|
18
|
+
configPath: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type DiscoveredProject = ResolvedConfigPaths;
|
|
22
|
+
|
|
23
|
+
export function resolveConfigPaths(options: ConfigPathOptions): ResolvedConfigPaths {
|
|
24
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
25
|
+
const workingDir = options.chdir ? resolve(cwd, options.chdir) : cwd;
|
|
26
|
+
if (options.config) {
|
|
27
|
+
const configPath = resolve(workingDir, options.config);
|
|
28
|
+
return {
|
|
29
|
+
projectDir: dirname(configPath),
|
|
30
|
+
configPath,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const projectDir = options.chdir ? workingDir : findNearestProjectDir(workingDir);
|
|
35
|
+
const configPath = join(projectDir, DEFAULT_CONFIG_FILE);
|
|
36
|
+
|
|
37
|
+
if (!existsSync(configPath)) {
|
|
38
|
+
throw new Error(formatConfigNotFoundAt(configPath, {
|
|
39
|
+
commandCwd: cwd,
|
|
40
|
+
hint: namedRigConfigFilesHint(projectDir),
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
projectDir,
|
|
46
|
+
configPath,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function discoverProjectConfigs(options: ConfigPathOptions = {}): DiscoveredProject[] {
|
|
51
|
+
if (options.config) return [resolveConfigPaths(options)];
|
|
52
|
+
|
|
53
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
54
|
+
const root = resolve(cwd, options.chdir ?? ".");
|
|
55
|
+
const projects: DiscoveredProject[] = [];
|
|
56
|
+
visitProjectDirs(root, projects);
|
|
57
|
+
return projects.sort((left, right) => left.configPath.localeCompare(right.configPath));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function findNearestProjectDir(start: string): string {
|
|
61
|
+
let current = start;
|
|
62
|
+
let hint: ConfigFilesHint | undefined;
|
|
63
|
+
for (;;) {
|
|
64
|
+
if (existsSync(join(current, DEFAULT_CONFIG_FILE))) return current;
|
|
65
|
+
hint ??= namedRigConfigFilesHint(current);
|
|
66
|
+
const parent = dirname(current);
|
|
67
|
+
if (parent === current) {
|
|
68
|
+
throw new Error(formatConfigNotFoundFrom(start, { commandCwd: start, hint }));
|
|
69
|
+
}
|
|
70
|
+
current = parent;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function visitProjectDirs(dir: string, projects: DiscoveredProject[]): void {
|
|
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
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let entries;
|
|
84
|
+
try {
|
|
85
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
86
|
+
} catch {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
if (!entry.isDirectory()) continue;
|
|
92
|
+
if (shouldSkipDiscoveryDir(entry.name)) continue;
|
|
93
|
+
visitProjectDirs(join(dir, entry.name), projects);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function shouldSkipDiscoveryDir(name: string): boolean {
|
|
98
|
+
return name === ".git" ||
|
|
99
|
+
name === ".rigkit" ||
|
|
100
|
+
name === "node_modules" ||
|
|
101
|
+
name === "dist" ||
|
|
102
|
+
name === "build";
|
|
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
|
+
}
|