@meshxdata/fops 0.0.1 → 0.0.3
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 +62 -40
- package/package.json +4 -3
- package/src/agent/agent.js +26 -34
- package/src/agent/context.js +155 -98
- package/src/agent/llm.js +54 -11
- package/src/auth/coda.js +128 -0
- package/src/auth/index.js +1 -0
- package/src/commands/index.js +23 -9
- package/src/doctor.js +143 -15
- package/src/plugins/api.js +9 -0
- package/src/plugins/index.js +1 -0
- package/src/plugins/knowledge.js +124 -0
- package/src/plugins/registry.js +1 -0
- package/src/setup/setup.js +10 -5
- package/src/setup/wizard.js +15 -11
- package/src/shell.js +2 -2
- package/src/skills/foundation/SKILL.md +200 -66
- package/src/ui/input.js +31 -34
- package/src/ui/spinner.js +36 -10
- package/STRUCTURE.md +0 -43
- package/src/agent/agent.test.js +0 -233
- package/src/agent/context.test.js +0 -81
- package/src/agent/llm.test.js +0 -139
- package/src/auth/keychain.test.js +0 -185
- package/src/auth/login.test.js +0 -192
- package/src/auth/oauth.test.js +0 -118
- package/src/auth/resolve.test.js +0 -153
- package/src/config.test.js +0 -70
- package/src/doctor.test.js +0 -134
- package/src/plugins/api.test.js +0 -95
- package/src/plugins/discovery.test.js +0 -92
- package/src/plugins/hooks.test.js +0 -118
- package/src/plugins/manifest.test.js +0 -106
- package/src/plugins/registry.test.js +0 -43
- package/src/plugins/skills.test.js +0 -173
- package/src/project.test.js +0 -196
- package/src/setup/aws.test.js +0 -280
- package/src/shell.test.js +0 -72
- package/src/ui/banner.test.js +0 -97
- package/src/ui/spinner.test.js +0 -29
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
|
|
6
|
-
vi.mock("execa", () => ({
|
|
7
|
-
execa: vi.fn(() => Promise.resolve({ stdout: "" })),
|
|
8
|
-
}));
|
|
9
|
-
|
|
10
|
-
describe("plugins/skills", () => {
|
|
11
|
-
let tmpHome;
|
|
12
|
-
|
|
13
|
-
beforeEach(() => {
|
|
14
|
-
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "fops-skills-"));
|
|
15
|
-
vi.spyOn(os, "homedir").mockReturnValue(tmpHome);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
afterEach(() => {
|
|
19
|
-
vi.restoreAllMocks();
|
|
20
|
-
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("loads skills from ~/.fops/skills/", async () => {
|
|
24
|
-
const skillDir = path.join(tmpHome, ".fops", "skills", "my-skill");
|
|
25
|
-
fs.mkdirSync(skillDir, { recursive: true });
|
|
26
|
-
fs.writeFileSync(
|
|
27
|
-
path.join(skillDir, "SKILL.md"),
|
|
28
|
-
`---\nname: My Skill\ndescription: A test skill\n---\nThis is the skill content.`
|
|
29
|
-
);
|
|
30
|
-
|
|
31
|
-
vi.resetModules();
|
|
32
|
-
vi.mock("execa", () => ({
|
|
33
|
-
execa: vi.fn(() => Promise.resolve({ stdout: "" })),
|
|
34
|
-
}));
|
|
35
|
-
const { loadSkills } = await import("./skills.js");
|
|
36
|
-
const skills = await loadSkills();
|
|
37
|
-
const userSkills = skills.filter((s) => s.name === "My Skill");
|
|
38
|
-
expect(userSkills).toHaveLength(1);
|
|
39
|
-
expect(userSkills[0].description).toBe("A test skill");
|
|
40
|
-
expect(userSkills[0].content).toBe("This is the skill content.");
|
|
41
|
-
expect(userSkills[0].pluginId).toBe(null);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("parses frontmatter correctly", async () => {
|
|
45
|
-
const skillDir = path.join(tmpHome, ".fops", "skills", "fm-test");
|
|
46
|
-
fs.mkdirSync(skillDir, { recursive: true });
|
|
47
|
-
fs.writeFileSync(
|
|
48
|
-
path.join(skillDir, "SKILL.md"),
|
|
49
|
-
`---\nname: FM Test\ndescription: Frontmatter test\n---\nBody text here.`
|
|
50
|
-
);
|
|
51
|
-
|
|
52
|
-
vi.resetModules();
|
|
53
|
-
vi.mock("execa", () => ({
|
|
54
|
-
execa: vi.fn(() => Promise.resolve({ stdout: "" })),
|
|
55
|
-
}));
|
|
56
|
-
const { loadSkills } = await import("./skills.js");
|
|
57
|
-
const skills = await loadSkills();
|
|
58
|
-
const skill = skills.find((s) => s.name === "FM Test");
|
|
59
|
-
expect(skill).toBeDefined();
|
|
60
|
-
expect(skill.content).toBe("Body text here.");
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("uses dir name as fallback name", async () => {
|
|
64
|
-
const skillDir = path.join(tmpHome, ".fops", "skills", "unnamed-skill");
|
|
65
|
-
fs.mkdirSync(skillDir, { recursive: true });
|
|
66
|
-
fs.writeFileSync(path.join(skillDir, "SKILL.md"), "Just a body, no frontmatter.");
|
|
67
|
-
|
|
68
|
-
vi.resetModules();
|
|
69
|
-
vi.mock("execa", () => ({
|
|
70
|
-
execa: vi.fn(() => Promise.resolve({ stdout: "" })),
|
|
71
|
-
}));
|
|
72
|
-
const { loadSkills } = await import("./skills.js");
|
|
73
|
-
const skills = await loadSkills();
|
|
74
|
-
const skill = skills.find((s) => s.name === "unnamed-skill");
|
|
75
|
-
expect(skill).toBeDefined();
|
|
76
|
-
expect(skill.content).toBe("Just a body, no frontmatter.");
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("includes skills from registry", async () => {
|
|
80
|
-
vi.resetModules();
|
|
81
|
-
vi.mock("execa", () => ({
|
|
82
|
-
execa: vi.fn(() => Promise.resolve({ stdout: "" })),
|
|
83
|
-
}));
|
|
84
|
-
const { loadSkills } = await import("./skills.js");
|
|
85
|
-
const registry = {
|
|
86
|
-
skills: [{ pluginId: "p1", name: "Plugin Skill", description: "From plugin", content: "plugin content" }],
|
|
87
|
-
};
|
|
88
|
-
const skills = await loadSkills(registry);
|
|
89
|
-
const pluginSkill = skills.find((s) => s.name === "Plugin Skill");
|
|
90
|
-
expect(pluginSkill).toBeDefined();
|
|
91
|
-
expect(pluginSkill.content).toBe("plugin content");
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("returns empty when no skills directories exist", async () => {
|
|
95
|
-
vi.resetModules();
|
|
96
|
-
vi.mock("execa", () => ({
|
|
97
|
-
execa: vi.fn(() => Promise.resolve({ stdout: "" })),
|
|
98
|
-
}));
|
|
99
|
-
const { loadSkills } = await import("./skills.js");
|
|
100
|
-
const skills = await loadSkills();
|
|
101
|
-
// May include built-in skills from src/skills, but no user skills
|
|
102
|
-
const userSkills = skills.filter((s) => s.pluginId === null);
|
|
103
|
-
// All returned skills should have correct shape
|
|
104
|
-
for (const s of skills) {
|
|
105
|
-
expect(typeof s.name).toBe("string");
|
|
106
|
-
expect(typeof s.content).toBe("string");
|
|
107
|
-
}
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it("returns empty when null registry is passed", async () => {
|
|
111
|
-
vi.resetModules();
|
|
112
|
-
vi.mock("execa", () => ({
|
|
113
|
-
execa: vi.fn(() => Promise.resolve({ stdout: "" })),
|
|
114
|
-
}));
|
|
115
|
-
const { loadSkills } = await import("./skills.js");
|
|
116
|
-
const skills = await loadSkills(null);
|
|
117
|
-
expect(Array.isArray(skills)).toBe(true);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it("skips dirs without SKILL.md", async () => {
|
|
121
|
-
const skillDir = path.join(tmpHome, ".fops", "skills", "no-skill-md");
|
|
122
|
-
fs.mkdirSync(skillDir, { recursive: true });
|
|
123
|
-
fs.writeFileSync(path.join(skillDir, "README.md"), "Not a skill");
|
|
124
|
-
|
|
125
|
-
vi.resetModules();
|
|
126
|
-
vi.mock("execa", () => ({
|
|
127
|
-
execa: vi.fn(() => Promise.resolve({ stdout: "" })),
|
|
128
|
-
}));
|
|
129
|
-
const { loadSkills } = await import("./skills.js");
|
|
130
|
-
const skills = await loadSkills();
|
|
131
|
-
const match = skills.find((s) => s.name === "no-skill-md");
|
|
132
|
-
expect(match).toBeUndefined();
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it("loads multiple skills from ~/.fops/skills/", async () => {
|
|
136
|
-
for (const name of ["skill-a", "skill-b"]) {
|
|
137
|
-
const dir = path.join(tmpHome, ".fops", "skills", name);
|
|
138
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
139
|
-
fs.writeFileSync(
|
|
140
|
-
path.join(dir, "SKILL.md"),
|
|
141
|
-
`---\nname: ${name}\ndescription: desc\n---\nContent for ${name}.`
|
|
142
|
-
);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
vi.resetModules();
|
|
146
|
-
vi.mock("execa", () => ({
|
|
147
|
-
execa: vi.fn(() => Promise.resolve({ stdout: "" })),
|
|
148
|
-
}));
|
|
149
|
-
const { loadSkills } = await import("./skills.js");
|
|
150
|
-
const skills = await loadSkills();
|
|
151
|
-
expect(skills.find((s) => s.name === "skill-a")).toBeDefined();
|
|
152
|
-
expect(skills.find((s) => s.name === "skill-b")).toBeDefined();
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it("handles multi-line frontmatter values", async () => {
|
|
156
|
-
const skillDir = path.join(tmpHome, ".fops", "skills", "multi-fm");
|
|
157
|
-
fs.mkdirSync(skillDir, { recursive: true });
|
|
158
|
-
fs.writeFileSync(
|
|
159
|
-
path.join(skillDir, "SKILL.md"),
|
|
160
|
-
`---\nname: Multi\ndescription: Has multiple fields\nrequires: nonexistent-binary\n---\nBody.`
|
|
161
|
-
);
|
|
162
|
-
|
|
163
|
-
vi.resetModules();
|
|
164
|
-
vi.mock("execa", () => ({
|
|
165
|
-
execa: vi.fn(() => { throw new Error("not found"); }),
|
|
166
|
-
}));
|
|
167
|
-
const { loadSkills } = await import("./skills.js");
|
|
168
|
-
const skills = await loadSkills();
|
|
169
|
-
// Should be filtered out due to requires gate
|
|
170
|
-
const match = skills.find((s) => s.name === "Multi");
|
|
171
|
-
expect(match).toBeUndefined();
|
|
172
|
-
});
|
|
173
|
-
});
|
package/src/project.test.js
DELETED
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import { hasComposeInDir, isFoundationRoot, findComposeRootUp, rootDir, requireRoot } from "./project.js";
|
|
6
|
-
|
|
7
|
-
function makeTmpDir() {
|
|
8
|
-
return fs.mkdtempSync(path.join(os.tmpdir(), "fops-test-"));
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
describe("project", () => {
|
|
12
|
-
let tmpDir;
|
|
13
|
-
|
|
14
|
-
beforeEach(() => {
|
|
15
|
-
tmpDir = makeTmpDir();
|
|
16
|
-
delete process.env.FOUNDATION_ROOT;
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
afterEach(() => {
|
|
20
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
describe("hasComposeInDir", () => {
|
|
24
|
-
it("returns false for empty dir", () => {
|
|
25
|
-
expect(hasComposeInDir(tmpDir)).toBe(false);
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it("returns true when docker-compose.yaml exists", () => {
|
|
29
|
-
fs.writeFileSync(path.join(tmpDir, "docker-compose.yaml"), "version: '3'\n");
|
|
30
|
-
expect(hasComposeInDir(tmpDir)).toBe(true);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it("returns true when docker-compose.yml exists", () => {
|
|
34
|
-
fs.writeFileSync(path.join(tmpDir, "docker-compose.yml"), "version: '3'\n");
|
|
35
|
-
expect(hasComposeInDir(tmpDir)).toBe(true);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("returns false when only unrelated files exist", () => {
|
|
39
|
-
fs.writeFileSync(path.join(tmpDir, "Makefile"), "");
|
|
40
|
-
fs.writeFileSync(path.join(tmpDir, "README.md"), "");
|
|
41
|
-
expect(hasComposeInDir(tmpDir)).toBe(false);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("returns true when both .yaml and .yml exist", () => {
|
|
45
|
-
fs.writeFileSync(path.join(tmpDir, "docker-compose.yaml"), "");
|
|
46
|
-
fs.writeFileSync(path.join(tmpDir, "docker-compose.yml"), "");
|
|
47
|
-
expect(hasComposeInDir(tmpDir)).toBe(true);
|
|
48
|
-
});
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
describe("isFoundationRoot", () => {
|
|
52
|
-
it("returns false with only docker-compose", () => {
|
|
53
|
-
fs.writeFileSync(path.join(tmpDir, "docker-compose.yaml"), "");
|
|
54
|
-
expect(isFoundationRoot(tmpDir)).toBe(false);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("returns false with only Makefile", () => {
|
|
58
|
-
fs.writeFileSync(path.join(tmpDir, "Makefile"), "");
|
|
59
|
-
expect(isFoundationRoot(tmpDir)).toBe(false);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("returns true with both docker-compose.yaml and Makefile", () => {
|
|
63
|
-
fs.writeFileSync(path.join(tmpDir, "docker-compose.yaml"), "");
|
|
64
|
-
fs.writeFileSync(path.join(tmpDir, "Makefile"), "");
|
|
65
|
-
expect(isFoundationRoot(tmpDir)).toBe(true);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("returns true with docker-compose.yml and Makefile", () => {
|
|
69
|
-
fs.writeFileSync(path.join(tmpDir, "docker-compose.yml"), "");
|
|
70
|
-
fs.writeFileSync(path.join(tmpDir, "Makefile"), "");
|
|
71
|
-
expect(isFoundationRoot(tmpDir)).toBe(true);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it("returns false for empty dir", () => {
|
|
75
|
-
expect(isFoundationRoot(tmpDir)).toBe(false);
|
|
76
|
-
});
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
describe("findComposeRootUp", () => {
|
|
80
|
-
it("returns null when nothing found", () => {
|
|
81
|
-
expect(findComposeRootUp(tmpDir)).toBe(null);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it("finds root in current dir", () => {
|
|
85
|
-
fs.writeFileSync(path.join(tmpDir, "docker-compose.yaml"), "");
|
|
86
|
-
fs.writeFileSync(path.join(tmpDir, "Makefile"), "");
|
|
87
|
-
expect(findComposeRootUp(tmpDir)).toBe(path.resolve(tmpDir));
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it("finds root in parent dir", () => {
|
|
91
|
-
fs.writeFileSync(path.join(tmpDir, "docker-compose.yaml"), "");
|
|
92
|
-
fs.writeFileSync(path.join(tmpDir, "Makefile"), "");
|
|
93
|
-
const child = path.join(tmpDir, "subdir");
|
|
94
|
-
fs.mkdirSync(child);
|
|
95
|
-
expect(findComposeRootUp(child)).toBe(path.resolve(tmpDir));
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it("finds root in grandparent dir", () => {
|
|
99
|
-
fs.writeFileSync(path.join(tmpDir, "docker-compose.yaml"), "");
|
|
100
|
-
fs.writeFileSync(path.join(tmpDir, "Makefile"), "");
|
|
101
|
-
const child = path.join(tmpDir, "a", "b");
|
|
102
|
-
fs.mkdirSync(child, { recursive: true });
|
|
103
|
-
expect(findComposeRootUp(child)).toBe(path.resolve(tmpDir));
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it("resolves relative paths", () => {
|
|
107
|
-
fs.writeFileSync(path.join(tmpDir, "docker-compose.yaml"), "");
|
|
108
|
-
fs.writeFileSync(path.join(tmpDir, "Makefile"), "");
|
|
109
|
-
// Uses path.resolve internally
|
|
110
|
-
expect(findComposeRootUp(tmpDir + "/./")).toBe(path.resolve(tmpDir));
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
describe("rootDir", () => {
|
|
115
|
-
it("returns null for empty dir tree", () => {
|
|
116
|
-
const emptyDir = makeTmpDir();
|
|
117
|
-
try {
|
|
118
|
-
expect(rootDir(emptyDir)).toBe(null);
|
|
119
|
-
} finally {
|
|
120
|
-
fs.rmSync(emptyDir, { recursive: true, force: true });
|
|
121
|
-
}
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it("uses FOUNDATION_ROOT env var when set", () => {
|
|
125
|
-
fs.writeFileSync(path.join(tmpDir, "docker-compose.yaml"), "");
|
|
126
|
-
fs.writeFileSync(path.join(tmpDir, "Makefile"), "");
|
|
127
|
-
process.env.FOUNDATION_ROOT = tmpDir;
|
|
128
|
-
expect(rootDir("/nonexistent")).toBe(path.resolve(tmpDir));
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it("ignores FOUNDATION_ROOT when path does not exist", () => {
|
|
132
|
-
process.env.FOUNDATION_ROOT = "/nonexistent/path/that/does/not/exist";
|
|
133
|
-
// Should fall through to cwd-based detection
|
|
134
|
-
fs.writeFileSync(path.join(tmpDir, "docker-compose.yaml"), "");
|
|
135
|
-
fs.writeFileSync(path.join(tmpDir, "Makefile"), "");
|
|
136
|
-
expect(rootDir(tmpDir)).toBe(path.resolve(tmpDir));
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it("detects root in cwd", () => {
|
|
140
|
-
fs.writeFileSync(path.join(tmpDir, "docker-compose.yaml"), "");
|
|
141
|
-
fs.writeFileSync(path.join(tmpDir, "Makefile"), "");
|
|
142
|
-
expect(rootDir(tmpDir)).toBe(path.resolve(tmpDir));
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it("detects root one level down", () => {
|
|
146
|
-
const child = path.join(tmpDir, "foundation-compose");
|
|
147
|
-
fs.mkdirSync(child);
|
|
148
|
-
fs.writeFileSync(path.join(child, "docker-compose.yaml"), "");
|
|
149
|
-
fs.writeFileSync(path.join(child, "Makefile"), "");
|
|
150
|
-
expect(rootDir(tmpDir)).toBe(path.resolve(child));
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it("prefers cwd match over child dir", () => {
|
|
154
|
-
// If cwd itself is a root, don't look into children
|
|
155
|
-
fs.writeFileSync(path.join(tmpDir, "docker-compose.yaml"), "");
|
|
156
|
-
fs.writeFileSync(path.join(tmpDir, "Makefile"), "");
|
|
157
|
-
const child = path.join(tmpDir, "nested");
|
|
158
|
-
fs.mkdirSync(child);
|
|
159
|
-
fs.writeFileSync(path.join(child, "docker-compose.yaml"), "");
|
|
160
|
-
fs.writeFileSync(path.join(child, "Makefile"), "");
|
|
161
|
-
expect(rootDir(tmpDir)).toBe(path.resolve(tmpDir));
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it("skips files when scanning one level down", () => {
|
|
165
|
-
// Only directories should be checked, not files
|
|
166
|
-
fs.writeFileSync(path.join(tmpDir, "some-file.txt"), "");
|
|
167
|
-
expect(rootDir(tmpDir)).toBe(null);
|
|
168
|
-
});
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
describe("requireRoot", () => {
|
|
172
|
-
it("returns root when project exists", () => {
|
|
173
|
-
fs.writeFileSync(path.join(tmpDir, "docker-compose.yaml"), "");
|
|
174
|
-
fs.writeFileSync(path.join(tmpDir, "Makefile"), "");
|
|
175
|
-
process.env.FOUNDATION_ROOT = tmpDir;
|
|
176
|
-
const mockProgram = { error: vi.fn() };
|
|
177
|
-
const result = requireRoot(mockProgram);
|
|
178
|
-
expect(result).toBe(path.resolve(tmpDir));
|
|
179
|
-
expect(mockProgram.error).not.toHaveBeenCalled();
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it("calls program.error when no project found", () => {
|
|
183
|
-
process.env.FOUNDATION_ROOT = "";
|
|
184
|
-
delete process.env.FOUNDATION_ROOT;
|
|
185
|
-
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
186
|
-
const mockProgram = { error: vi.fn() };
|
|
187
|
-
// rootDir will search from cwd which is the operator-cli dir (a real project)
|
|
188
|
-
// So we need to make sure FOUNDATION_ROOT doesn't point to anything
|
|
189
|
-
// and cwd doesn't have the files. We mock rootDir behavior indirectly.
|
|
190
|
-
// For this test, just verify the function signature works.
|
|
191
|
-
requireRoot(mockProgram);
|
|
192
|
-
// If real project found, no error. If not, error called.
|
|
193
|
-
// Either way, function returns something or calls error.
|
|
194
|
-
});
|
|
195
|
-
});
|
|
196
|
-
});
|
package/src/setup/aws.test.js
DELETED
|
@@ -1,280 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
|
|
6
|
-
describe("setup/aws", () => {
|
|
7
|
-
let tmpDir;
|
|
8
|
-
|
|
9
|
-
beforeEach(() => {
|
|
10
|
-
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fops-aws-"));
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
afterEach(() => {
|
|
14
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
describe("readFopsConfig / saveFopsConfig", () => {
|
|
18
|
-
it("readFopsConfig returns empty object when file does not exist", () => {
|
|
19
|
-
const configPath = path.join(tmpDir, ".fops.json");
|
|
20
|
-
expect(fs.existsSync(configPath)).toBe(false);
|
|
21
|
-
let config = {};
|
|
22
|
-
try {
|
|
23
|
-
if (fs.existsSync(configPath)) {
|
|
24
|
-
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
25
|
-
}
|
|
26
|
-
} catch {}
|
|
27
|
-
expect(config).toEqual({});
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("saveFopsConfig writes valid JSON", () => {
|
|
31
|
-
const configPath = path.join(tmpDir, ".fops.json");
|
|
32
|
-
const data = { aws: { profile: "dev" } };
|
|
33
|
-
fs.writeFileSync(configPath, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
|
|
34
|
-
const read = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
35
|
-
expect(read).toEqual(data);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("readFopsConfig handles corrupted JSON gracefully", () => {
|
|
39
|
-
const configPath = path.join(tmpDir, ".fops.json");
|
|
40
|
-
fs.writeFileSync(configPath, "not valid json{");
|
|
41
|
-
let config = {};
|
|
42
|
-
try {
|
|
43
|
-
if (fs.existsSync(configPath)) {
|
|
44
|
-
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
45
|
-
}
|
|
46
|
-
} catch {}
|
|
47
|
-
expect(config).toEqual({});
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("round-trips nested config", () => {
|
|
51
|
-
const configPath = path.join(tmpDir, ".fops.json");
|
|
52
|
-
const data = {
|
|
53
|
-
aws: { profile: "dev", region: "us-east-1" },
|
|
54
|
-
plugins: { entries: { "my-plugin": { enabled: true, config: { key: "val" } } } },
|
|
55
|
-
};
|
|
56
|
-
fs.writeFileSync(configPath, JSON.stringify(data, null, 2) + "\n");
|
|
57
|
-
const read = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
58
|
-
expect(read).toEqual(data);
|
|
59
|
-
});
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
describe("detectEcrRegistry", () => {
|
|
63
|
-
let detectEcrRegistry;
|
|
64
|
-
|
|
65
|
-
beforeEach(async () => {
|
|
66
|
-
({ detectEcrRegistry } = await import("./aws.js"));
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("returns null when no docker-compose.yaml", () => {
|
|
70
|
-
expect(detectEcrRegistry(tmpDir)).toBe(null);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it("returns null when no ECR URL in compose", () => {
|
|
74
|
-
fs.writeFileSync(path.join(tmpDir, "docker-compose.yaml"), "version: '3'\nservices:\n web:\n image: nginx\n");
|
|
75
|
-
expect(detectEcrRegistry(tmpDir)).toBe(null);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it("extracts ECR info from docker-compose.yaml", () => {
|
|
79
|
-
fs.writeFileSync(
|
|
80
|
-
path.join(tmpDir, "docker-compose.yaml"),
|
|
81
|
-
`services:\n app:\n image: 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-app:latest\n`
|
|
82
|
-
);
|
|
83
|
-
const result = detectEcrRegistry(tmpDir);
|
|
84
|
-
expect(result).toEqual({ accountId: "123456789012", region: "us-east-1" });
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it("extracts different regions", () => {
|
|
88
|
-
fs.writeFileSync(
|
|
89
|
-
path.join(tmpDir, "docker-compose.yaml"),
|
|
90
|
-
`services:\n app:\n image: 987654321098.dkr.ecr.eu-west-1.amazonaws.com/app:v1\n`
|
|
91
|
-
);
|
|
92
|
-
const result = detectEcrRegistry(tmpDir);
|
|
93
|
-
expect(result).toEqual({ accountId: "987654321098", region: "eu-west-1" });
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it("handles multiple ECR references (returns first match)", () => {
|
|
97
|
-
fs.writeFileSync(
|
|
98
|
-
path.join(tmpDir, "docker-compose.yaml"),
|
|
99
|
-
[
|
|
100
|
-
"services:",
|
|
101
|
-
" app1:",
|
|
102
|
-
" image: 111111111111.dkr.ecr.us-east-1.amazonaws.com/app1:latest",
|
|
103
|
-
" app2:",
|
|
104
|
-
" image: 222222222222.dkr.ecr.eu-west-1.amazonaws.com/app2:latest",
|
|
105
|
-
].join("\n")
|
|
106
|
-
);
|
|
107
|
-
const result = detectEcrRegistry(tmpDir);
|
|
108
|
-
expect(result.accountId).toBe("111111111111");
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("returns null for compose with no image directives", () => {
|
|
112
|
-
fs.writeFileSync(
|
|
113
|
-
path.join(tmpDir, "docker-compose.yaml"),
|
|
114
|
-
"services:\n app:\n build: .\n"
|
|
115
|
-
);
|
|
116
|
-
expect(detectEcrRegistry(tmpDir)).toBe(null);
|
|
117
|
-
});
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
describe("detectAwsSsoProfiles", () => {
|
|
121
|
-
let detectAwsSsoProfiles;
|
|
122
|
-
|
|
123
|
-
beforeEach(async () => {
|
|
124
|
-
({ detectAwsSsoProfiles } = await import("./aws.js"));
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it("returns an array", () => {
|
|
128
|
-
const result = detectAwsSsoProfiles();
|
|
129
|
-
expect(Array.isArray(result)).toBe(true);
|
|
130
|
-
});
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
describe("detectAwsSsoProfiles parsing logic", () => {
|
|
134
|
-
it("parses SSO profiles from aws config content", () => {
|
|
135
|
-
const configContent = `[profile dev]
|
|
136
|
-
sso_session = meshx
|
|
137
|
-
sso_account_id = 123456789012
|
|
138
|
-
sso_role_name = AdministratorAccess
|
|
139
|
-
region = us-east-1
|
|
140
|
-
|
|
141
|
-
[sso-session meshx]
|
|
142
|
-
sso_start_url = https://meshx.awsapps.com/start
|
|
143
|
-
sso_region = us-east-1
|
|
144
|
-
|
|
145
|
-
[profile staging]
|
|
146
|
-
sso_session = meshx
|
|
147
|
-
sso_account_id = 987654321098
|
|
148
|
-
sso_role_name = ReadOnly
|
|
149
|
-
region = us-west-2
|
|
150
|
-
`;
|
|
151
|
-
// Inline the parsing logic (same as detectAwsSsoProfiles)
|
|
152
|
-
const content = configContent;
|
|
153
|
-
const profiles = [];
|
|
154
|
-
let currentProfile = null;
|
|
155
|
-
let currentAttrs = {};
|
|
156
|
-
for (const line of content.split("\n")) {
|
|
157
|
-
const profileMatch = line.match(/^\[profile\s+(.+?)\]/);
|
|
158
|
-
if (profileMatch) {
|
|
159
|
-
if (currentProfile && currentAttrs.sso_session) {
|
|
160
|
-
profiles.push({ name: currentProfile, ...currentAttrs });
|
|
161
|
-
}
|
|
162
|
-
currentProfile = profileMatch[1];
|
|
163
|
-
currentAttrs = {};
|
|
164
|
-
continue;
|
|
165
|
-
}
|
|
166
|
-
if (line.startsWith("[")) {
|
|
167
|
-
if (currentProfile && currentAttrs.sso_session) {
|
|
168
|
-
profiles.push({ name: currentProfile, ...currentAttrs });
|
|
169
|
-
}
|
|
170
|
-
currentProfile = null;
|
|
171
|
-
currentAttrs = {};
|
|
172
|
-
continue;
|
|
173
|
-
}
|
|
174
|
-
const kv = line.match(/^\s*(\S+)\s*=\s*(.+)/);
|
|
175
|
-
if (kv && currentProfile) {
|
|
176
|
-
currentAttrs[kv[1]] = kv[2].trim();
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
if (currentProfile && currentAttrs.sso_session) {
|
|
180
|
-
profiles.push({ name: currentProfile, ...currentAttrs });
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
expect(profiles).toHaveLength(2);
|
|
184
|
-
expect(profiles[0].name).toBe("dev");
|
|
185
|
-
expect(profiles[0].sso_session).toBe("meshx");
|
|
186
|
-
expect(profiles[0].sso_account_id).toBe("123456789012");
|
|
187
|
-
expect(profiles[0].region).toBe("us-east-1");
|
|
188
|
-
expect(profiles[1].name).toBe("staging");
|
|
189
|
-
expect(profiles[1].sso_account_id).toBe("987654321098");
|
|
190
|
-
expect(profiles[1].region).toBe("us-west-2");
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
it("skips profiles without sso_session", () => {
|
|
194
|
-
const content = `[profile default]
|
|
195
|
-
region = us-east-1
|
|
196
|
-
output = json
|
|
197
|
-
|
|
198
|
-
[profile sso-dev]
|
|
199
|
-
sso_session = meshx
|
|
200
|
-
region = us-east-1
|
|
201
|
-
`;
|
|
202
|
-
const profiles = [];
|
|
203
|
-
let currentProfile = null;
|
|
204
|
-
let currentAttrs = {};
|
|
205
|
-
for (const line of content.split("\n")) {
|
|
206
|
-
const profileMatch = line.match(/^\[profile\s+(.+?)\]/);
|
|
207
|
-
if (profileMatch) {
|
|
208
|
-
if (currentProfile && currentAttrs.sso_session) {
|
|
209
|
-
profiles.push({ name: currentProfile, ...currentAttrs });
|
|
210
|
-
}
|
|
211
|
-
currentProfile = profileMatch[1];
|
|
212
|
-
currentAttrs = {};
|
|
213
|
-
continue;
|
|
214
|
-
}
|
|
215
|
-
if (line.startsWith("[")) {
|
|
216
|
-
if (currentProfile && currentAttrs.sso_session) {
|
|
217
|
-
profiles.push({ name: currentProfile, ...currentAttrs });
|
|
218
|
-
}
|
|
219
|
-
currentProfile = null;
|
|
220
|
-
currentAttrs = {};
|
|
221
|
-
continue;
|
|
222
|
-
}
|
|
223
|
-
const kv = line.match(/^\s*(\S+)\s*=\s*(.+)/);
|
|
224
|
-
if (kv && currentProfile) {
|
|
225
|
-
currentAttrs[kv[1]] = kv[2].trim();
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
if (currentProfile && currentAttrs.sso_session) {
|
|
229
|
-
profiles.push({ name: currentProfile, ...currentAttrs });
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
expect(profiles).toHaveLength(1);
|
|
233
|
-
expect(profiles[0].name).toBe("sso-dev");
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
it("handles empty config", () => {
|
|
237
|
-
const profiles = [];
|
|
238
|
-
// No profiles to parse
|
|
239
|
-
expect(profiles).toHaveLength(0);
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
it("handles profile at end of file (no trailing section)", () => {
|
|
243
|
-
const content = `[profile last]
|
|
244
|
-
sso_session = test
|
|
245
|
-
region = us-east-1`;
|
|
246
|
-
const profiles = [];
|
|
247
|
-
let currentProfile = null;
|
|
248
|
-
let currentAttrs = {};
|
|
249
|
-
for (const line of content.split("\n")) {
|
|
250
|
-
const profileMatch = line.match(/^\[profile\s+(.+?)\]/);
|
|
251
|
-
if (profileMatch) {
|
|
252
|
-
if (currentProfile && currentAttrs.sso_session) {
|
|
253
|
-
profiles.push({ name: currentProfile, ...currentAttrs });
|
|
254
|
-
}
|
|
255
|
-
currentProfile = profileMatch[1];
|
|
256
|
-
currentAttrs = {};
|
|
257
|
-
continue;
|
|
258
|
-
}
|
|
259
|
-
if (line.startsWith("[")) {
|
|
260
|
-
if (currentProfile && currentAttrs.sso_session) {
|
|
261
|
-
profiles.push({ name: currentProfile, ...currentAttrs });
|
|
262
|
-
}
|
|
263
|
-
currentProfile = null;
|
|
264
|
-
currentAttrs = {};
|
|
265
|
-
continue;
|
|
266
|
-
}
|
|
267
|
-
const kv = line.match(/^\s*(\S+)\s*=\s*(.+)/);
|
|
268
|
-
if (kv && currentProfile) {
|
|
269
|
-
currentAttrs[kv[1]] = kv[2].trim();
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
if (currentProfile && currentAttrs.sso_session) {
|
|
273
|
-
profiles.push({ name: currentProfile, ...currentAttrs });
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
expect(profiles).toHaveLength(1);
|
|
277
|
-
expect(profiles[0].name).toBe("last");
|
|
278
|
-
});
|
|
279
|
-
});
|
|
280
|
-
});
|