@rigkit/cli 0.1.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.
- package/README.md +25 -0
- package/package.json +45 -0
- package/src/cli.test.ts +253 -0
- package/src/cli.ts +1911 -0
- package/src/completion.test.ts +204 -0
- package/src/completion.ts +444 -0
- package/src/init.test.ts +83 -0
- package/src/init.ts +242 -0
- package/src/interaction.test.ts +28 -0
- package/src/interaction.ts +33 -0
- package/src/project.test.ts +51 -0
- package/src/project.ts +94 -0
- package/src/remote-project.test.ts +55 -0
- package/src/remote-project.ts +225 -0
- package/src/run-presenter.ts +373 -0
- package/src/version.ts +1 -0
package/src/init.test.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
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 { FREESTYLE_PROVIDER_PACKAGE_NAME, PROJECT_PACKAGE_NAME } from "./project.ts";
|
|
7
|
+
import { RIGKIT_CLI_VERSION } from "./version.ts";
|
|
8
|
+
|
|
9
|
+
describe("initProject", () => {
|
|
10
|
+
test("creates a full Rigkit project", () => {
|
|
11
|
+
const parentDir = mkdtempSync(join(tmpdir(), "rigkit-init-"));
|
|
12
|
+
const projectDir = join(parentDir, "platform-api");
|
|
13
|
+
const result = initProject({
|
|
14
|
+
projectDir,
|
|
15
|
+
configPath: join(projectDir, "rig.config.ts"),
|
|
16
|
+
name: "Platform API",
|
|
17
|
+
apiKey: "fs_test_123",
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
expect(result.name).toBe("platform-api");
|
|
21
|
+
expect(result.projectDir).toBe(projectDir);
|
|
22
|
+
expect(existsSync(projectDir)).toBe(true);
|
|
23
|
+
expect(result.created).toEqual({
|
|
24
|
+
config: true,
|
|
25
|
+
env: true,
|
|
26
|
+
envExample: true,
|
|
27
|
+
gitignore: true,
|
|
28
|
+
packageJson: true,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(readFileSync(join(projectDir, "rig.config.ts"), "utf8")).toContain('sequence("platform-api"');
|
|
32
|
+
expect(readFileSync(join(projectDir, "rig.config.ts"), "utf8")).toContain("defineConfig({");
|
|
33
|
+
expect(readFileSync(join(projectDir, ".env"), "utf8")).toBe("FREESTYLE_API_KEY=fs_test_123\n");
|
|
34
|
+
expect(readFileSync(join(projectDir, ".env.example"), "utf8")).toBe("FREESTYLE_API_KEY=\n");
|
|
35
|
+
expect(readFileSync(join(projectDir, ".gitignore"), "utf8")).toContain(".env\n.rigkit/\n");
|
|
36
|
+
|
|
37
|
+
const pkg = JSON.parse(readFileSync(join(projectDir, "package.json"), "utf8"));
|
|
38
|
+
expect(pkg.name).toBe("platform-api");
|
|
39
|
+
expect(pkg.scripts.plan).toBe("rig plan");
|
|
40
|
+
expect(pkg.scripts.apply).toBe("rig apply");
|
|
41
|
+
expect(pkg.devDependencies[PROJECT_PACKAGE_NAME]).toBe(RIGKIT_CLI_VERSION);
|
|
42
|
+
expect(pkg.devDependencies[FREESTYLE_PROVIDER_PACKAGE_NAME]).toBe(RIGKIT_CLI_VERSION);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("updates existing project files without replacing package metadata", () => {
|
|
46
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-init-"));
|
|
47
|
+
mkdirSync(projectDir, { recursive: true });
|
|
48
|
+
writeFileSync(join(projectDir, ".env"), "OTHER=value\nFREESTYLE_API_KEY=old\n");
|
|
49
|
+
writeFileSync(join(projectDir, ".gitignore"), "node_modules/\n");
|
|
50
|
+
writeFileSync(
|
|
51
|
+
join(projectDir, "package.json"),
|
|
52
|
+
`${JSON.stringify({ name: "existing", scripts: { test: "bun test" } }, null, 2)}\n`,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const result = initProject({
|
|
56
|
+
projectDir,
|
|
57
|
+
configPath: join(projectDir, "rig.config.ts"),
|
|
58
|
+
name: "dev",
|
|
59
|
+
apiKey: "new-key",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(result.created.packageJson).toBe(false);
|
|
63
|
+
expect(result.updated.packageJson).toBe(true);
|
|
64
|
+
expect(readFileSync(join(projectDir, ".env"), "utf8")).toBe("OTHER=value\nFREESTYLE_API_KEY=new-key\n");
|
|
65
|
+
expect(readFileSync(join(projectDir, ".gitignore"), "utf8")).toBe("node_modules/\n.env\n.rigkit/\n");
|
|
66
|
+
|
|
67
|
+
const pkg = JSON.parse(readFileSync(join(projectDir, "package.json"), "utf8"));
|
|
68
|
+
expect(pkg.name).toBe("existing");
|
|
69
|
+
expect(pkg.scripts.test).toBe("bun test");
|
|
70
|
+
expect(pkg.scripts.plan).toBe("rig plan");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("normalizeMachineName", () => {
|
|
75
|
+
test("normalizes human names into machine names", () => {
|
|
76
|
+
expect(normalizeMachineName(" My Platform API ")).toBe("my-platform-api");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("rejects empty names", () => {
|
|
80
|
+
expect(() => normalizeMachineName(" ")).toThrow("Project name is required.");
|
|
81
|
+
expect(() => normalizeMachineName("!!!")).toThrow("Project name is required.");
|
|
82
|
+
});
|
|
83
|
+
});
|
package/src/init.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
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 { FREESTYLE_PROVIDER_PACKAGE_NAME, PROJECT_PACKAGE_NAME } from "./project.ts";
|
|
5
|
+
|
|
6
|
+
export type InitProjectInput = {
|
|
7
|
+
projectDir: string;
|
|
8
|
+
configPath: string;
|
|
9
|
+
name: string;
|
|
10
|
+
apiKey: string;
|
|
11
|
+
force?: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type InitProjectResult = {
|
|
15
|
+
name: string;
|
|
16
|
+
projectDir: string;
|
|
17
|
+
configPath: string;
|
|
18
|
+
envPath: string;
|
|
19
|
+
envExamplePath: string;
|
|
20
|
+
gitignorePath: string;
|
|
21
|
+
packageJsonPath: string;
|
|
22
|
+
created: {
|
|
23
|
+
config: boolean;
|
|
24
|
+
env: boolean;
|
|
25
|
+
envExample: boolean;
|
|
26
|
+
packageJson: boolean;
|
|
27
|
+
gitignore: boolean;
|
|
28
|
+
};
|
|
29
|
+
updated: {
|
|
30
|
+
envApiKey: boolean;
|
|
31
|
+
gitignore: boolean;
|
|
32
|
+
packageJson: boolean;
|
|
33
|
+
sdkDependency: boolean;
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function initProject(input: InitProjectInput): InitProjectResult {
|
|
38
|
+
const name = normalizeMachineName(input.name);
|
|
39
|
+
mkdirSync(input.projectDir, { recursive: true });
|
|
40
|
+
|
|
41
|
+
if (existsSync(input.configPath) && !input.force) {
|
|
42
|
+
throw new Error(`${input.configPath} already exists. Pass --force to overwrite it.`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const wroteConfig = !existsSync(input.configPath) || Boolean(input.force);
|
|
46
|
+
if (wroteConfig) {
|
|
47
|
+
writeFileSync(input.configPath, starterConfig(name));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const envPath = join(input.projectDir, ".env");
|
|
51
|
+
const env = writeEnvFile(envPath, input.apiKey);
|
|
52
|
+
|
|
53
|
+
const envExamplePath = join(input.projectDir, ".env.example");
|
|
54
|
+
const wroteEnvExample = !existsSync(envExamplePath);
|
|
55
|
+
if (wroteEnvExample) {
|
|
56
|
+
writeFileSync(envExamplePath, "FREESTYLE_API_KEY=\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const gitignore = ensureGitignore(input.projectDir);
|
|
60
|
+
const packageJson = ensureProjectPackageJson(input.projectDir, name);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
name,
|
|
64
|
+
projectDir: input.projectDir,
|
|
65
|
+
configPath: input.configPath,
|
|
66
|
+
envPath,
|
|
67
|
+
envExamplePath,
|
|
68
|
+
gitignorePath: gitignore.path,
|
|
69
|
+
packageJsonPath: packageJson.path,
|
|
70
|
+
created: {
|
|
71
|
+
config: wroteConfig,
|
|
72
|
+
env: env.created,
|
|
73
|
+
envExample: wroteEnvExample,
|
|
74
|
+
gitignore: gitignore.created,
|
|
75
|
+
packageJson: packageJson.created,
|
|
76
|
+
},
|
|
77
|
+
updated: {
|
|
78
|
+
envApiKey: env.updated,
|
|
79
|
+
gitignore: gitignore.updated,
|
|
80
|
+
packageJson: packageJson.updated,
|
|
81
|
+
sdkDependency: packageJson.sdkDependencyChanged,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function normalizeMachineName(value: string): string {
|
|
87
|
+
const name = value.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
88
|
+
if (!name) {
|
|
89
|
+
throw new Error("Project name is required.");
|
|
90
|
+
}
|
|
91
|
+
return name;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function starterConfig(name: string): string {
|
|
95
|
+
const workflowName = JSON.stringify(normalizeMachineName(name));
|
|
96
|
+
|
|
97
|
+
return `import { defineConfig, env, sequence } from "@rigkit/sdk";
|
|
98
|
+
import { freestyle } from "@rigkit/provider-freestyle";
|
|
99
|
+
|
|
100
|
+
const freestyleProvider = freestyle.provider({
|
|
101
|
+
apiKey: () => env.secret("FREESTYLE_API_KEY"),
|
|
102
|
+
image: "node-22",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const dev = sequence(${workflowName})
|
|
106
|
+
.step("verify-node-22", async ({ providers }) => {
|
|
107
|
+
const vm = await providers.freestyle.vms.create();
|
|
108
|
+
const result = await vm.probe("node --version", { name: "node is v22" });
|
|
109
|
+
if (!result.ok || !result.stdout.trim().startsWith("v22.")) {
|
|
110
|
+
throw new Error(\`Expected Node.js v22, got: \${result.stdout}\${result.stderr}\`);
|
|
111
|
+
}
|
|
112
|
+
return { vm: await vm.snapshotRef() };
|
|
113
|
+
})
|
|
114
|
+
.create(async ({ ctx, name, providers }) => {
|
|
115
|
+
const vm = await providers.freestyle.vms.fromSnapshot(ctx.vm);
|
|
116
|
+
return {
|
|
117
|
+
name,
|
|
118
|
+
providerId: "freestyle",
|
|
119
|
+
resourceId: vm.vmId,
|
|
120
|
+
vmId: vm.vmId,
|
|
121
|
+
sourceRef: ctx.vm,
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
export default defineConfig({
|
|
126
|
+
providers: {
|
|
127
|
+
freestyle: freestyleProvider,
|
|
128
|
+
},
|
|
129
|
+
workflows: {
|
|
130
|
+
dev,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function writeEnvFile(path: string, apiKey: string): { created: boolean; updated: boolean } {
|
|
137
|
+
const created = !existsSync(path);
|
|
138
|
+
const existing = created ? "" : readFileSync(path, "utf8");
|
|
139
|
+
const lines = existing ? existing.split(/\r?\n/) : [];
|
|
140
|
+
const nextLine = `FREESTYLE_API_KEY=${apiKey}`;
|
|
141
|
+
let found = false;
|
|
142
|
+
let updated = created;
|
|
143
|
+
|
|
144
|
+
const next = lines.map((line) => {
|
|
145
|
+
if (!line.startsWith("FREESTYLE_API_KEY=")) return line;
|
|
146
|
+
found = true;
|
|
147
|
+
if (line === nextLine) return line;
|
|
148
|
+
updated = true;
|
|
149
|
+
return nextLine;
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (!found) {
|
|
153
|
+
if (next.length > 0 && next[next.length - 1] !== "") next.push("");
|
|
154
|
+
next.push(nextLine);
|
|
155
|
+
updated = true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (updated) {
|
|
159
|
+
writeFileSync(path, `${next.join("\n").replace(/\n+$/, "")}\n`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { created, updated };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function ensureGitignore(projectDir: string): { path: string; created: boolean; updated: boolean } {
|
|
166
|
+
const path = join(projectDir, ".gitignore");
|
|
167
|
+
const created = !existsSync(path);
|
|
168
|
+
const existing = created ? "" : readFileSync(path, "utf8");
|
|
169
|
+
const entries = existing.split(/\r?\n/).filter(Boolean);
|
|
170
|
+
let updated = false;
|
|
171
|
+
|
|
172
|
+
for (const entry of [".env", ".rigkit/"]) {
|
|
173
|
+
if (!entries.includes(entry)) {
|
|
174
|
+
entries.push(entry);
|
|
175
|
+
updated = true;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (created || updated) {
|
|
180
|
+
writeFileSync(path, `${entries.join("\n")}\n`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { path, created, updated: created || updated };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function ensureProjectPackageJson(
|
|
187
|
+
projectDir: string,
|
|
188
|
+
name: string,
|
|
189
|
+
): { path: string; created: boolean; updated: boolean; sdkDependencyChanged: boolean } {
|
|
190
|
+
const path = join(projectDir, "package.json");
|
|
191
|
+
const created = !existsSync(path);
|
|
192
|
+
const pkg = created
|
|
193
|
+
? {
|
|
194
|
+
name,
|
|
195
|
+
private: true,
|
|
196
|
+
type: "module",
|
|
197
|
+
scripts: {},
|
|
198
|
+
}
|
|
199
|
+
: JSON.parse(readFileSync(path, "utf8")) as Record<string, any>;
|
|
200
|
+
|
|
201
|
+
let updated = created;
|
|
202
|
+
|
|
203
|
+
if (!isRecord(pkg.scripts)) {
|
|
204
|
+
pkg.scripts = {};
|
|
205
|
+
updated = true;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const scripts = pkg.scripts as Record<string, string>;
|
|
209
|
+
for (const [key, value] of Object.entries({ plan: "rig plan", apply: "rig apply" })) {
|
|
210
|
+
if (scripts[key] !== value) {
|
|
211
|
+
scripts[key] = value;
|
|
212
|
+
updated = true;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
pkg.scripts = sortObject(scripts);
|
|
216
|
+
|
|
217
|
+
const devDependencies = isRecord(pkg.devDependencies) ? pkg.devDependencies : {};
|
|
218
|
+
const sdkDependencyChanged =
|
|
219
|
+
devDependencies[PROJECT_PACKAGE_NAME] !== RIGKIT_CLI_VERSION ||
|
|
220
|
+
devDependencies[FREESTYLE_PROVIDER_PACKAGE_NAME] !== RIGKIT_CLI_VERSION;
|
|
221
|
+
if (sdkDependencyChanged) {
|
|
222
|
+
delete devDependencies["@rigkit/runtime"];
|
|
223
|
+
devDependencies[PROJECT_PACKAGE_NAME] = RIGKIT_CLI_VERSION;
|
|
224
|
+
devDependencies[FREESTYLE_PROVIDER_PACKAGE_NAME] = RIGKIT_CLI_VERSION;
|
|
225
|
+
updated = true;
|
|
226
|
+
}
|
|
227
|
+
pkg.devDependencies = sortObject(devDependencies);
|
|
228
|
+
|
|
229
|
+
if (created || updated) {
|
|
230
|
+
writeFileSync(path, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { path, created, updated, sdkDependencyChanged };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function isRecord(value: unknown): value is Record<string, any> {
|
|
237
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function sortObject<T>(value: Record<string, T>): Record<string, T> {
|
|
241
|
+
return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)));
|
|
242
|
+
}
|
|
@@ -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,51 @@
|
|
|
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 -C 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, project: "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("discovers projects downward without entering dependency directories", () => {
|
|
38
|
+
const cwd = mkdtempSync(join(tmpdir(), "rigkit-cli-"));
|
|
39
|
+
mkdirSync(join(cwd, "api"), { recursive: true });
|
|
40
|
+
mkdirSync(join(cwd, "node_modules", "ignored"), { recursive: true });
|
|
41
|
+
writeFileSync(join(cwd, "api", "rig.config.ts"), "export default {}\n");
|
|
42
|
+
writeFileSync(join(cwd, "node_modules", "ignored", "rig.config.ts"), "export default {}\n");
|
|
43
|
+
|
|
44
|
+
const projects = discoverProjectConfigs({ cwd });
|
|
45
|
+
|
|
46
|
+
expect(projects).toEqual([{
|
|
47
|
+
projectDir: join(cwd, "api"),
|
|
48
|
+
configPath: join(cwd, "api", "rig.config.ts"),
|
|
49
|
+
}]);
|
|
50
|
+
});
|
|
51
|
+
});
|
package/src/project.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { dirname, join, 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
|
+
|
|
8
|
+
export type ConfigPathOptions = {
|
|
9
|
+
project?: string;
|
|
10
|
+
config?: string;
|
|
11
|
+
cwd?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type ResolvedConfigPaths = {
|
|
15
|
+
projectDir: string;
|
|
16
|
+
configPath: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type DiscoveredProject = ResolvedConfigPaths;
|
|
20
|
+
|
|
21
|
+
export function resolveConfigPaths(options: ConfigPathOptions): ResolvedConfigPaths {
|
|
22
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
23
|
+
if (options.config) {
|
|
24
|
+
const projectBase = options.project ? resolve(cwd, options.project) : cwd;
|
|
25
|
+
const configPath = resolve(projectBase, options.config);
|
|
26
|
+
return {
|
|
27
|
+
projectDir: dirname(configPath),
|
|
28
|
+
configPath,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const projectDir = options.project ? resolve(cwd, options.project) : findNearestProjectDir(cwd);
|
|
33
|
+
const configPath = join(projectDir, DEFAULT_CONFIG_FILE);
|
|
34
|
+
|
|
35
|
+
if (!existsSync(configPath)) {
|
|
36
|
+
throw new Error(`No Rigkit config found at ${configPath}. Run "rig init" or pass --config <file>.`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
projectDir,
|
|
41
|
+
configPath,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function discoverProjectConfigs(options: ConfigPathOptions = {}): DiscoveredProject[] {
|
|
46
|
+
if (options.config) return [resolveConfigPaths(options)];
|
|
47
|
+
|
|
48
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
49
|
+
const root = resolve(cwd, options.project ?? ".");
|
|
50
|
+
const projects: DiscoveredProject[] = [];
|
|
51
|
+
visitProjectDirs(root, projects);
|
|
52
|
+
return projects.sort((left, right) => left.configPath.localeCompare(right.configPath));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function findNearestProjectDir(start: string): string {
|
|
56
|
+
let current = start;
|
|
57
|
+
for (;;) {
|
|
58
|
+
if (existsSync(join(current, DEFAULT_CONFIG_FILE))) return current;
|
|
59
|
+
const parent = dirname(current);
|
|
60
|
+
if (parent === current) {
|
|
61
|
+
throw new Error(`No Rigkit config found from ${start} upward. Run "rig init" or pass --config <file>.`);
|
|
62
|
+
}
|
|
63
|
+
current = parent;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function visitProjectDirs(dir: string, projects: DiscoveredProject[]): void {
|
|
68
|
+
const configPath = join(dir, DEFAULT_CONFIG_FILE);
|
|
69
|
+
if (existsSync(configPath)) {
|
|
70
|
+
projects.push({ projectDir: dir, configPath });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let entries;
|
|
75
|
+
try {
|
|
76
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
77
|
+
} catch {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
if (!entry.isDirectory()) continue;
|
|
83
|
+
if (shouldSkipDiscoveryDir(entry.name)) continue;
|
|
84
|
+
visitProjectDirs(join(dir, entry.name), projects);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function shouldSkipDiscoveryDir(name: string): boolean {
|
|
89
|
+
return name === ".git" ||
|
|
90
|
+
name === ".rigkit" ||
|
|
91
|
+
name === "node_modules" ||
|
|
92
|
+
name === "dist" ||
|
|
93
|
+
name === "build";
|
|
94
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
parseGithubProjectTarget,
|
|
4
|
+
remoteProjectId,
|
|
5
|
+
splitGithubProjectTarget,
|
|
6
|
+
} from "./remote-project.ts";
|
|
7
|
+
|
|
8
|
+
describe("remote GitHub project targets", () => {
|
|
9
|
+
test("parses github owner repo targets with optional refs", () => {
|
|
10
|
+
expect(parseGithubProjectTarget("github:freestyle-sh/rigkit")).toEqual({
|
|
11
|
+
kind: "github",
|
|
12
|
+
raw: "github:freestyle-sh/rigkit",
|
|
13
|
+
owner: "freestyle-sh",
|
|
14
|
+
repo: "rigkit",
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
expect(parseGithubProjectTarget("github:freestyle-sh/rigkit#feature/runtime")).toEqual({
|
|
18
|
+
kind: "github",
|
|
19
|
+
raw: "github:freestyle-sh/rigkit#feature/runtime",
|
|
20
|
+
owner: "freestyle-sh",
|
|
21
|
+
repo: "rigkit",
|
|
22
|
+
ref: "feature/runtime",
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("splits a run target from operation arguments", () => {
|
|
27
|
+
const split = splitGithubProjectTarget(["github:freestyle-sh/rigkit@main", "--workflow", "smoke"]);
|
|
28
|
+
|
|
29
|
+
expect(split.target).toEqual({
|
|
30
|
+
kind: "github",
|
|
31
|
+
raw: "github:freestyle-sh/rigkit@main",
|
|
32
|
+
owner: "freestyle-sh",
|
|
33
|
+
repo: "rigkit",
|
|
34
|
+
ref: "main",
|
|
35
|
+
});
|
|
36
|
+
expect(split.args).toEqual(["--workflow", "smoke"]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("remote project ids include repo, ref, commit, and config path", () => {
|
|
40
|
+
const id = remoteProjectId({
|
|
41
|
+
repoUrl: "https://github.com/freestyle-sh/rigkit.git",
|
|
42
|
+
ref: "main",
|
|
43
|
+
commitSha: "0123456789abcdef0123456789abcdef01234567",
|
|
44
|
+
configPath: "rig.config.ts",
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(id).toMatch(/^github-[a-f0-9]{32}$/);
|
|
48
|
+
expect(remoteProjectId({
|
|
49
|
+
repoUrl: "https://github.com/freestyle-sh/rigkit.git",
|
|
50
|
+
ref: "main",
|
|
51
|
+
commitSha: "fedcba9876543210fedcba9876543210fedcba98",
|
|
52
|
+
configPath: "rig.config.ts",
|
|
53
|
+
})).not.toBe(id);
|
|
54
|
+
});
|
|
55
|
+
});
|