@fatagnus/remote-cmd-relay-convex 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,331 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import {
3
+ createRelayTestConvex,
4
+ createTestRelayAssignment,
5
+ createTestCommand,
6
+ type RelayTestConvex,
7
+ } from "./test.setup";
8
+ import { api } from "./_generated/api";
9
+
10
+ describe("command queue", () => {
11
+ let t: RelayTestConvex;
12
+
13
+ beforeEach(() => {
14
+ vi.useFakeTimers();
15
+ t = createRelayTestConvex();
16
+ });
17
+
18
+ afterEach(async () => {
19
+ await t.finishAllScheduledFunctions(vi.runAllTimers);
20
+ vi.useRealTimers();
21
+ });
22
+
23
+ describe("queue", () => {
24
+ it("queues a local command", async () => {
25
+ const result = await t.mutation(api.commands.queue, {
26
+ machineId: "machine-1",
27
+ command: "echo hello",
28
+ targetType: "local",
29
+ createdBy: "user-1",
30
+ });
31
+
32
+ expect(result).toBeDefined();
33
+
34
+ const cmd = await t.run(async (ctx) => {
35
+ return await ctx.db.get(result);
36
+ });
37
+
38
+ expect(cmd?.machineId).toBe("machine-1");
39
+ expect(cmd?.command).toBe("echo hello");
40
+ expect(cmd?.targetType).toBe("local");
41
+ expect(cmd?.status).toBe("pending");
42
+ expect(cmd?.timeoutMs).toBe(30000); // default
43
+ });
44
+
45
+ it("queues an SSH command with target details", async () => {
46
+ const result = await t.mutation(api.commands.queue, {
47
+ machineId: "machine-1",
48
+ command: "uptime",
49
+ targetType: "ssh",
50
+ targetHost: "192.168.1.100",
51
+ targetPort: 2222,
52
+ targetUsername: "admin",
53
+ timeoutMs: 60000,
54
+ createdBy: "user-1",
55
+ });
56
+
57
+ const cmd = await t.run(async (ctx) => {
58
+ return await ctx.db.get(result);
59
+ });
60
+
61
+ expect(cmd?.targetType).toBe("ssh");
62
+ expect(cmd?.targetHost).toBe("192.168.1.100");
63
+ expect(cmd?.targetPort).toBe(2222);
64
+ expect(cmd?.targetUsername).toBe("admin");
65
+ expect(cmd?.timeoutMs).toBe(60000);
66
+ });
67
+
68
+ it("requires targetHost and targetUsername for SSH commands", async () => {
69
+ await expect(
70
+ t.mutation(api.commands.queue, {
71
+ machineId: "machine-1",
72
+ command: "uptime",
73
+ targetType: "ssh",
74
+ createdBy: "user-1",
75
+ })
76
+ ).rejects.toThrow("SSH target requires targetHost and targetUsername");
77
+ });
78
+ });
79
+
80
+ describe("listPending", () => {
81
+ it("lists pending commands for a machine", async () => {
82
+ await createTestCommand(t, {
83
+ machineId: "machine-1",
84
+ command: "echo 1",
85
+ status: "pending",
86
+ });
87
+ await createTestCommand(t, {
88
+ machineId: "machine-1",
89
+ command: "echo 2",
90
+ status: "pending",
91
+ });
92
+ await createTestCommand(t, {
93
+ machineId: "machine-1",
94
+ command: "echo completed",
95
+ status: "completed",
96
+ });
97
+ await createTestCommand(t, {
98
+ machineId: "machine-2",
99
+ command: "echo other",
100
+ status: "pending",
101
+ });
102
+
103
+ const result = await t.query(api.commands.listPending, {
104
+ machineId: "machine-1",
105
+ });
106
+
107
+ expect(result).toHaveLength(2);
108
+ expect(result.every((c) => c.status === "pending")).toBe(true);
109
+ expect(result.every((c) => c.machineId === "machine-1")).toBe(true);
110
+ });
111
+
112
+ it("respects limit parameter", async () => {
113
+ for (let i = 0; i < 5; i++) {
114
+ await createTestCommand(t, {
115
+ machineId: "machine-1",
116
+ command: `echo ${i}`,
117
+ status: "pending",
118
+ });
119
+ }
120
+
121
+ const result = await t.query(api.commands.listPending, {
122
+ machineId: "machine-1",
123
+ limit: 2,
124
+ });
125
+
126
+ expect(result).toHaveLength(2);
127
+ });
128
+ });
129
+
130
+ describe("get", () => {
131
+ it("returns command by ID", async () => {
132
+ const created = await createTestCommand(t, {
133
+ machineId: "machine-1",
134
+ command: "test command",
135
+ });
136
+
137
+ const result = await t.query(api.commands.get, {
138
+ id: created._id,
139
+ });
140
+
141
+ expect(result).not.toBeNull();
142
+ expect(result?.command).toBe("test command");
143
+ expect(result?.machineId).toBe("machine-1");
144
+ });
145
+
146
+ it("returns null for non-existent command", async () => {
147
+ const created = await createTestCommand(t, { machineId: "m" });
148
+ await t.run(async (ctx) => {
149
+ await ctx.db.delete(created._id);
150
+ });
151
+
152
+ const result = await t.query(api.commands.get, {
153
+ id: created._id,
154
+ });
155
+
156
+ expect(result).toBeNull();
157
+ });
158
+ });
159
+
160
+ describe("claim", () => {
161
+ it("claims a pending command", async () => {
162
+ const created = await createTestCommand(t, {
163
+ machineId: "machine-1",
164
+ status: "pending",
165
+ });
166
+
167
+ const result = await t.mutation(api.commands.claim, {
168
+ id: created._id,
169
+ claimedBy: "relay-1",
170
+ });
171
+
172
+ expect(result).toBe(true);
173
+
174
+ const cmd = await t.run(async (ctx) => {
175
+ return await ctx.db.get(created._id);
176
+ });
177
+
178
+ expect(cmd?.status).toBe("claimed");
179
+ expect(cmd?.claimedBy).toBe("relay-1");
180
+ expect(cmd?.claimedAt).toBeDefined();
181
+ });
182
+
183
+ it("prevents double claiming", async () => {
184
+ const created = await createTestCommand(t, {
185
+ machineId: "machine-1",
186
+ status: "pending",
187
+ });
188
+
189
+ // First claim succeeds
190
+ const result1 = await t.mutation(api.commands.claim, {
191
+ id: created._id,
192
+ claimedBy: "relay-1",
193
+ });
194
+ expect(result1).toBe(true);
195
+
196
+ // Second claim fails
197
+ const result2 = await t.mutation(api.commands.claim, {
198
+ id: created._id,
199
+ claimedBy: "relay-2",
200
+ });
201
+ expect(result2).toBe(false);
202
+ });
203
+
204
+ it("throws for non-existent command", async () => {
205
+ const created = await createTestCommand(t, { machineId: "m" });
206
+ await t.run(async (ctx) => {
207
+ await ctx.db.delete(created._id);
208
+ });
209
+
210
+ await expect(
211
+ t.mutation(api.commands.claim, {
212
+ id: created._id,
213
+ claimedBy: "relay-1",
214
+ })
215
+ ).rejects.toThrow("Command not found");
216
+ });
217
+ });
218
+
219
+ describe("startExecution", () => {
220
+ it("updates status to executing", async () => {
221
+ const created = await createTestCommand(t, {
222
+ machineId: "machine-1",
223
+ status: "claimed",
224
+ });
225
+
226
+ await t.mutation(api.commands.startExecution, {
227
+ id: created._id,
228
+ });
229
+
230
+ const cmd = await t.run(async (ctx) => {
231
+ return await ctx.db.get(created._id);
232
+ });
233
+
234
+ expect(cmd?.status).toBe("executing");
235
+ });
236
+ });
237
+
238
+ describe("complete", () => {
239
+ it("completes command with success", async () => {
240
+ const created = await createTestCommand(t, {
241
+ machineId: "machine-1",
242
+ status: "executing",
243
+ });
244
+
245
+ await t.mutation(api.commands.complete, {
246
+ id: created._id,
247
+ success: true,
248
+ output: "Hello, World!",
249
+ exitCode: 0,
250
+ durationMs: 150,
251
+ });
252
+
253
+ const cmd = await t.run(async (ctx) => {
254
+ return await ctx.db.get(created._id);
255
+ });
256
+
257
+ expect(cmd?.status).toBe("completed");
258
+ expect(cmd?.output).toBe("Hello, World!");
259
+ expect(cmd?.exitCode).toBe(0);
260
+ expect(cmd?.durationMs).toBe(150);
261
+ expect(cmd?.completedAt).toBeDefined();
262
+ });
263
+
264
+ it("completes command with failure", async () => {
265
+ const created = await createTestCommand(t, {
266
+ machineId: "machine-1",
267
+ status: "executing",
268
+ });
269
+
270
+ await t.mutation(api.commands.complete, {
271
+ id: created._id,
272
+ success: false,
273
+ stderr: "Command failed",
274
+ exitCode: 1,
275
+ error: "Non-zero exit code",
276
+ durationMs: 50,
277
+ });
278
+
279
+ const cmd = await t.run(async (ctx) => {
280
+ return await ctx.db.get(created._id);
281
+ });
282
+
283
+ expect(cmd?.status).toBe("failed");
284
+ expect(cmd?.stderr).toBe("Command failed");
285
+ expect(cmd?.exitCode).toBe(1);
286
+ expect(cmd?.error).toBe("Non-zero exit code");
287
+ });
288
+ });
289
+
290
+ describe("listRecent", () => {
291
+ it("lists recent commands for a machine", async () => {
292
+ await createTestCommand(t, {
293
+ machineId: "machine-1",
294
+ command: "echo 1",
295
+ status: "completed",
296
+ });
297
+ await createTestCommand(t, {
298
+ machineId: "machine-1",
299
+ command: "echo 2",
300
+ status: "failed",
301
+ });
302
+ await createTestCommand(t, {
303
+ machineId: "machine-1",
304
+ command: "echo 3",
305
+ status: "pending",
306
+ });
307
+
308
+ const result = await t.query(api.commands.listRecent, {
309
+ machineId: "machine-1",
310
+ });
311
+
312
+ expect(result).toHaveLength(3);
313
+ });
314
+
315
+ it("respects limit parameter", async () => {
316
+ for (let i = 0; i < 10; i++) {
317
+ await createTestCommand(t, {
318
+ machineId: "machine-1",
319
+ command: `echo ${i}`,
320
+ });
321
+ }
322
+
323
+ const result = await t.query(api.commands.listRecent, {
324
+ machineId: "machine-1",
325
+ limit: 5,
326
+ });
327
+
328
+ expect(result).toHaveLength(5);
329
+ });
330
+ });
331
+ });
@@ -0,0 +1,286 @@
1
+ import { v } from "convex/values";
2
+ import { mutation, query } from "./_generated/server";
3
+ import { commandStatusValidator, targetTypeValidator } from "./schema";
4
+
5
+ /**
6
+ * Queue a new command for execution
7
+ */
8
+ export const queue = mutation({
9
+ args: {
10
+ machineId: v.string(),
11
+ command: v.string(),
12
+ targetType: targetTypeValidator,
13
+ targetHost: v.optional(v.string()),
14
+ targetPort: v.optional(v.number()),
15
+ targetUsername: v.optional(v.string()),
16
+ timeoutMs: v.optional(v.number()),
17
+ createdBy: v.string(),
18
+ },
19
+ returns: v.id("commandQueue"),
20
+ handler: async (ctx, args) => {
21
+ const now = Date.now();
22
+
23
+ // Validate SSH target details if targetType is ssh
24
+ if (args.targetType === "ssh") {
25
+ if (!args.targetHost || !args.targetUsername) {
26
+ throw new Error("SSH target requires targetHost and targetUsername");
27
+ }
28
+ }
29
+
30
+ return await ctx.db.insert("commandQueue", {
31
+ machineId: args.machineId,
32
+ command: args.command,
33
+ targetType: args.targetType,
34
+ targetHost: args.targetHost,
35
+ targetPort: args.targetPort ?? 22,
36
+ targetUsername: args.targetUsername,
37
+ timeoutMs: args.timeoutMs ?? 30000,
38
+ status: "pending",
39
+ createdBy: args.createdBy,
40
+ createdAt: now,
41
+ updatedAt: now,
42
+ });
43
+ },
44
+ });
45
+
46
+ /**
47
+ * Get pending commands for a machine
48
+ */
49
+ export const listPending = query({
50
+ args: {
51
+ machineId: v.string(),
52
+ limit: v.optional(v.number()),
53
+ },
54
+ returns: v.array(
55
+ v.object({
56
+ _id: v.id("commandQueue"),
57
+ machineId: v.string(),
58
+ command: v.string(),
59
+ targetType: targetTypeValidator,
60
+ targetHost: v.optional(v.string()),
61
+ targetPort: v.optional(v.number()),
62
+ targetUsername: v.optional(v.string()),
63
+ timeoutMs: v.number(),
64
+ status: commandStatusValidator,
65
+ createdBy: v.string(),
66
+ createdAt: v.number(),
67
+ })
68
+ ),
69
+ handler: async (ctx, args) => {
70
+ const commands = await ctx.db
71
+ .query("commandQueue")
72
+ .withIndex("by_machineId_status", (q) =>
73
+ q.eq("machineId", args.machineId).eq("status", "pending")
74
+ )
75
+ .order("asc")
76
+ .take(args.limit ?? 10);
77
+
78
+ return commands.map((c) => ({
79
+ _id: c._id,
80
+ machineId: c.machineId,
81
+ command: c.command,
82
+ targetType: c.targetType,
83
+ targetHost: c.targetHost,
84
+ targetPort: c.targetPort,
85
+ targetUsername: c.targetUsername,
86
+ timeoutMs: c.timeoutMs,
87
+ status: c.status,
88
+ createdBy: c.createdBy,
89
+ createdAt: c.createdAt,
90
+ }));
91
+ },
92
+ });
93
+
94
+ /**
95
+ * Get a command by ID
96
+ */
97
+ export const get = query({
98
+ args: {
99
+ id: v.id("commandQueue"),
100
+ },
101
+ returns: v.union(
102
+ v.object({
103
+ _id: v.id("commandQueue"),
104
+ machineId: v.string(),
105
+ command: v.string(),
106
+ targetType: targetTypeValidator,
107
+ targetHost: v.optional(v.string()),
108
+ targetPort: v.optional(v.number()),
109
+ targetUsername: v.optional(v.string()),
110
+ timeoutMs: v.number(),
111
+ status: commandStatusValidator,
112
+ claimedBy: v.optional(v.string()),
113
+ claimedAt: v.optional(v.number()),
114
+ output: v.optional(v.string()),
115
+ stderr: v.optional(v.string()),
116
+ exitCode: v.optional(v.number()),
117
+ error: v.optional(v.string()),
118
+ durationMs: v.optional(v.number()),
119
+ completedAt: v.optional(v.number()),
120
+ createdBy: v.string(),
121
+ createdAt: v.number(),
122
+ updatedAt: v.number(),
123
+ }),
124
+ v.null()
125
+ ),
126
+ handler: async (ctx, args) => {
127
+ const cmd = await ctx.db.get(args.id);
128
+ if (!cmd) return null;
129
+
130
+ return {
131
+ _id: cmd._id,
132
+ machineId: cmd.machineId,
133
+ command: cmd.command,
134
+ targetType: cmd.targetType,
135
+ targetHost: cmd.targetHost,
136
+ targetPort: cmd.targetPort,
137
+ targetUsername: cmd.targetUsername,
138
+ timeoutMs: cmd.timeoutMs,
139
+ status: cmd.status,
140
+ claimedBy: cmd.claimedBy,
141
+ claimedAt: cmd.claimedAt,
142
+ output: cmd.output,
143
+ stderr: cmd.stderr,
144
+ exitCode: cmd.exitCode,
145
+ error: cmd.error,
146
+ durationMs: cmd.durationMs,
147
+ completedAt: cmd.completedAt,
148
+ createdBy: cmd.createdBy,
149
+ createdAt: cmd.createdAt,
150
+ updatedAt: cmd.updatedAt,
151
+ };
152
+ },
153
+ });
154
+
155
+ /**
156
+ * Claim a command for execution
157
+ */
158
+ export const claim = mutation({
159
+ args: {
160
+ id: v.id("commandQueue"),
161
+ claimedBy: v.string(), // Relay assignment ID
162
+ },
163
+ returns: v.boolean(),
164
+ handler: async (ctx, args) => {
165
+ const cmd = await ctx.db.get(args.id);
166
+ if (!cmd) {
167
+ throw new Error("Command not found");
168
+ }
169
+
170
+ // Only claim pending commands
171
+ if (cmd.status !== "pending") {
172
+ return false;
173
+ }
174
+
175
+ await ctx.db.patch(args.id, {
176
+ status: "claimed",
177
+ claimedBy: args.claimedBy,
178
+ claimedAt: Date.now(),
179
+ updatedAt: Date.now(),
180
+ });
181
+
182
+ return true;
183
+ },
184
+ });
185
+
186
+ /**
187
+ * Mark a command as executing
188
+ */
189
+ export const startExecution = mutation({
190
+ args: {
191
+ id: v.id("commandQueue"),
192
+ },
193
+ returns: v.null(),
194
+ handler: async (ctx, args) => {
195
+ const cmd = await ctx.db.get(args.id);
196
+ if (!cmd) {
197
+ throw new Error("Command not found");
198
+ }
199
+
200
+ await ctx.db.patch(args.id, {
201
+ status: "executing",
202
+ updatedAt: Date.now(),
203
+ });
204
+ return null;
205
+ },
206
+ });
207
+
208
+ /**
209
+ * Complete a command with results
210
+ */
211
+ export const complete = mutation({
212
+ args: {
213
+ id: v.id("commandQueue"),
214
+ success: v.boolean(),
215
+ output: v.optional(v.string()),
216
+ stderr: v.optional(v.string()),
217
+ exitCode: v.optional(v.number()),
218
+ error: v.optional(v.string()),
219
+ durationMs: v.optional(v.number()),
220
+ },
221
+ returns: v.null(),
222
+ handler: async (ctx, args) => {
223
+ const cmd = await ctx.db.get(args.id);
224
+ if (!cmd) {
225
+ throw new Error("Command not found");
226
+ }
227
+
228
+ const now = Date.now();
229
+
230
+ await ctx.db.patch(args.id, {
231
+ status: args.success ? "completed" : "failed",
232
+ output: args.output,
233
+ stderr: args.stderr,
234
+ exitCode: args.exitCode,
235
+ error: args.error,
236
+ durationMs: args.durationMs,
237
+ completedAt: now,
238
+ updatedAt: now,
239
+ });
240
+ return null;
241
+ },
242
+ });
243
+
244
+ /**
245
+ * List recent commands for a machine
246
+ */
247
+ export const listRecent = query({
248
+ args: {
249
+ machineId: v.string(),
250
+ limit: v.optional(v.number()),
251
+ },
252
+ returns: v.array(
253
+ v.object({
254
+ _id: v.id("commandQueue"),
255
+ machineId: v.string(),
256
+ command: v.string(),
257
+ targetType: targetTypeValidator,
258
+ status: commandStatusValidator,
259
+ exitCode: v.optional(v.number()),
260
+ error: v.optional(v.string()),
261
+ durationMs: v.optional(v.number()),
262
+ createdAt: v.number(),
263
+ completedAt: v.optional(v.number()),
264
+ })
265
+ ),
266
+ handler: async (ctx, args) => {
267
+ const commands = await ctx.db
268
+ .query("commandQueue")
269
+ .withIndex("by_machineId", (q) => q.eq("machineId", args.machineId))
270
+ .order("desc")
271
+ .take(args.limit ?? 50);
272
+
273
+ return commands.map((c) => ({
274
+ _id: c._id,
275
+ machineId: c.machineId,
276
+ command: c.command,
277
+ targetType: c.targetType,
278
+ status: c.status,
279
+ exitCode: c.exitCode,
280
+ error: c.error,
281
+ durationMs: c.durationMs,
282
+ createdAt: c.createdAt,
283
+ completedAt: c.completedAt,
284
+ }));
285
+ },
286
+ });
@@ -0,0 +1,11 @@
1
+ // Re-export all component modules for easy importing
2
+ export { default as component } from "./convex.config.js";
3
+ export { default as schema } from "./schema.js";
4
+
5
+ // Export all functions
6
+ export * as assignments from "./assignments.js";
7
+ export * as commands from "./commands.js";
8
+ export * as status from "./status.js";
9
+ export * as credentials from "./credentials.js";
10
+ export * as configPush from "./configPush.js";
11
+ export * as publicApi from "./public.js";