@rigkit/cli 0.2.2 → 0.2.4

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 CHANGED
@@ -16,7 +16,7 @@ Interactive terminals use Inquirer prompts and a chalk/log-update run timeline.
16
16
 
17
17
  Interactive providers can ask the CLI to open provider-owned URLs. For example, Freestyle terminal sessions are served by the Freestyle provider, while the CLI only opens the presented URL in a browser.
18
18
 
19
- `rig ssh <workspace>` runs the workflow's uncached `workspace.onOpen` hook before attaching or printing the SSH command.
19
+ Workspace-specific operations run as `rig <workspace>/<operation>`, for example `rig website-workspace/open-cmux` or `rig website-workspace/remove -y`.
20
20
 
21
21
  `rig ls` lists workspaces for the selected project. `rig ls snapshots` lists cached snapshot runs, and `rig ls config` shows the resolved project paths.
22
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigkit/cli",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,9 +25,9 @@
25
25
  "log-symbols": "^7.0.1",
26
26
  "log-update": "^8.0.0",
27
27
  "ora": "^9.4.0",
28
- "@rigkit/runtime-client": "0.2.2",
29
- "@rigkit/provider-cmux": "0.2.2",
30
- "@rigkit/engine": "0.2.2"
28
+ "@rigkit/provider-cmux": "0.2.4",
29
+ "@rigkit/engine": "0.2.4",
30
+ "@rigkit/runtime-client": "0.2.4"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/bun": "latest",
package/src/cli.test.ts CHANGED
@@ -83,8 +83,8 @@ describe("CLI entrypoint", () => {
83
83
 
84
84
  expect(result.exitCode).toBe(0);
85
85
  expect(result.stderr).toBe("");
86
- expect(result.stdout).toContain("name resource snapshot workflow");
87
- expect(result.stdout).toContain("api vm-api snap-api smoke");
86
+ expect(result.stdout).toContain("name workflow");
87
+ expect(result.stdout).toContain("api smoke");
88
88
  });
89
89
  });
90
90
 
@@ -100,8 +100,7 @@ describe("CLI entrypoint", () => {
100
100
  workspaces: [{
101
101
  name: "api",
102
102
  workflow: "smoke",
103
- resourceId: "vm-api",
104
- snapshotId: "snap-api",
103
+ ctx: {},
105
104
  }],
106
105
  });
107
106
  });
@@ -210,14 +209,8 @@ async function withWorkspaceRuntime(
210
209
  workspaces: [{
211
210
  id: "workspace-api",
212
211
  name: "api",
213
- providerId: "freestyle",
214
212
  workflow: "smoke",
215
- resourceId: "vm-api",
216
- snapshotId: "snap-api",
217
- sourceRef: null,
218
- context: {},
219
- metadata: {},
220
- data: {},
213
+ ctx: {},
221
214
  createdAt: now,
222
215
  updatedAt: now,
223
216
  }],
package/src/cli.ts CHANGED
@@ -94,15 +94,8 @@ type EngineProjectInfo = {
94
94
  };
95
95
 
96
96
  type RuntimeOperationManifest = {
97
- hostMethods?: {
98
- known?: Array<{ id: string; modes?: string[] }>;
99
- requiredByOperations?: Record<string, string[]>;
100
- };
101
- hostCapabilities?: {
102
- optional?: Array<{ id: string; schemaHash?: string }>;
103
- requiredByOperations?: Record<string, string[]>;
104
- };
105
97
  operations: RuntimeOperationDefinition[];
98
+ workspaceOperations?: RuntimeOperationDefinition[];
106
99
  };
107
100
 
108
101
  type RuntimeOperationDefinition = {
@@ -111,8 +104,6 @@ type RuntimeOperationDefinition = {
111
104
  title?: string;
112
105
  description?: string;
113
106
  createsWorkspace?: boolean;
114
- requiredHostMethods?: Array<{ id: string; modes?: string[] }>;
115
- requiredHostCapabilities?: Array<{ id: string; schemaHash?: string }>;
116
107
  cli?: {
117
108
  positionals?: Array<{ name: string; index: number }>;
118
109
  options?: Array<{
@@ -414,29 +405,29 @@ async function runInit(invocation: CliInvocation, options: InitOptions): Promise
414
405
  async function resolveInitAnswers(
415
406
  options: InitOptions,
416
407
  jsonMode: boolean,
417
- ): Promise<{ name: string; apiKey: string; packageManager: PackageManager }> {
418
- if (jsonMode && (options.name === undefined || !options.apiKey?.trim())) {
419
- throw new Error(`rig init --json requires --name and --api-key`);
408
+ ): Promise<{ name: string; apiKey?: string; packageManager: PackageManager }> {
409
+ if (jsonMode && options.name === undefined) {
410
+ throw new Error(`rig init --json requires --name`);
420
411
  }
421
412
 
422
413
  if (jsonMode && options.packageManager && options.packageManager !== "skip") {
423
414
  throw new Error(`rig init --json only supports --package-manager skip`);
424
415
  }
425
416
 
426
- if (options.name === undefined || !options.apiKey) {
417
+ if (options.name === undefined) {
427
418
  assertInteractiveInit();
428
419
  }
429
420
 
430
421
  if (!jsonMode) {
431
422
  console.log(chalk.bold("Initialize Rigkit"));
432
- console.log(chalk.dim("This creates a project folder with rig.config.ts, .env, package.json, and local ignore rules."));
423
+ console.log(chalk.dim("This creates a project folder with rig.config.ts, package.json, and local ignore rules."));
433
424
  console.log("");
434
425
  }
435
426
 
436
427
  const name = options.name !== undefined
437
428
  ? normalizeMachineName(options.name)
438
429
  : await promptName();
439
- const apiKey = options.apiKey?.trim() || await promptRequiredSecret("Freestyle API key");
430
+ const apiKey = options.apiKey?.trim();
440
431
  const packageManager = options.packageManager ?? (jsonMode || !canPrompt() ? "skip" : await promptPackageManager("skip"));
441
432
 
442
433
  return {
@@ -448,7 +439,7 @@ async function resolveInitAnswers(
448
439
 
449
440
  function assertInteractiveInit(): void {
450
441
  if (canPrompt()) return;
451
- throw new Error(`rig init needs --name and --api-key when not running in an interactive terminal`);
442
+ throw new Error(`rig init needs --name when not running in an interactive terminal`);
452
443
  }
453
444
 
454
445
  function canPrompt(): boolean {
@@ -487,14 +478,6 @@ async function promptName(): Promise<string> {
487
478
  return answers.name;
488
479
  }
489
480
 
490
- async function promptRequiredSecret(label: string): Promise<string> {
491
- for (;;) {
492
- const value = (await promptSecret(label)).trim();
493
- if (value) return value;
494
- console.log(chalk.red(`${label} is required.`));
495
- }
496
- }
497
-
498
481
  async function promptPackageManager(defaultValue: PackageManager): Promise<PackageManager> {
499
482
  const choices: Array<{ value: PackageManager; label: string; hint: string }> = [
500
483
  { value: "npm", label: "npm", hint: "npm install" },
@@ -516,16 +499,6 @@ async function promptPackageManager(defaultValue: PackageManager): Promise<Packa
516
499
  return answers.packageManager;
517
500
  }
518
501
 
519
- async function promptSecret(label: string): Promise<string> {
520
- const answers = await inquirer.prompt<{ value: string }>([{
521
- type: "password",
522
- name: "value",
523
- message: `${label}:`,
524
- mask: "*",
525
- }]);
526
- return answers.value;
527
- }
528
-
529
502
  async function runPackageManagerInstall(
530
503
  projectDir: string,
531
504
  packageManager: PackageManager,
@@ -801,18 +774,18 @@ async function executeRuntimeOperation(
801
774
  result: unknown;
802
775
  }> {
803
776
  const manifest = await readRuntimeOperations(runtime);
804
- const operation = findRuntimeOperation(manifest.operations, requestedOperation);
805
- if (!operation) {
777
+ const resolved = findRuntimeOperation(manifest, requestedOperation);
778
+ if (!resolved) {
806
779
  throw new Error(`This project does not define a Rigkit operation named "${requestedOperation}".`);
807
780
  }
781
+ const { operation, runOperation } = resolved;
808
782
 
809
- preflightHostSupport(operation);
810
783
  const parsed = parseOperationArgs(operation, args);
811
784
  enforceHostOnlyBooleanGuards(operation, parsed);
812
785
 
813
786
  const result = await runRuntimeOperation<unknown>(
814
787
  runtime,
815
- operation.id,
788
+ runOperation,
816
789
  parsed.input,
817
790
  { renderEvents: !wantsJson(invocation) },
818
791
  );
@@ -821,50 +794,28 @@ async function executeRuntimeOperation(
821
794
  }
822
795
 
823
796
  function findRuntimeOperation(
824
- operations: RuntimeOperationDefinition[],
797
+ manifest: RuntimeOperationManifest,
825
798
  requestedOperation: string,
826
- ): RuntimeOperationDefinition | undefined {
827
- return operations.find((operation) =>
828
- operation.id === requestedOperation || operation.aliases?.includes(requestedOperation)
829
- );
830
- }
831
-
832
- function preflightHostSupport(operation: RuntimeOperationDefinition): void {
833
- const unsupportedMethod = operation.requiredHostMethods?.find((method) => {
834
- const supported = CLI_HOST_METHODS.find((item) => item.id === method.id);
835
- return !supported || !supportsModes(supported.modes, method.modes);
836
- });
837
- if (unsupportedMethod) {
838
- throw new Error(
839
- `Operation "${operation.id}" requires host method "${formatMethodRequirement(unsupportedMethod)}". ` +
840
- `Upgrade Rigkit or use a host that supports this method.`,
841
- );
842
- }
843
-
844
- const unsupportedCapability = operation.requiredHostCapabilities?.find((capability) => {
845
- const supported = CLI_HOST_CAPABILITIES.find((item) => item.id === capability.id);
846
- return !supported || (capability.schemaHash && supported.schemaHash !== capability.schemaHash);
847
- });
848
- if (unsupportedCapability) {
849
- throw new Error(
850
- `Operation "${operation.id}" requires host capability "${formatCapabilityRequirement(unsupportedCapability)}". ` +
851
- `Install or enable a local host capability handler to use it from this host.`,
852
- );
799
+ ): { operation: RuntimeOperationDefinition; runOperation: string } | undefined {
800
+ const workspaceOperation = parseWorkspaceOperationId(requestedOperation);
801
+ if (workspaceOperation) {
802
+ const operation = (manifest.workspaceOperations ?? []).find((item) => item.id === workspaceOperation.operation);
803
+ return operation ? { operation, runOperation: requestedOperation } : undefined;
853
804
  }
854
- }
855
-
856
- function supportsModes(hostModes: string[] | undefined, requiredModes: string[] | undefined): boolean {
857
- if (!requiredModes?.length) return true;
858
- const supported = new Set(hostModes ?? []);
859
- return requiredModes.every((mode) => supported.has(mode));
860
- }
861
805
 
862
- function formatMethodRequirement(method: { id: string; modes?: string[] }): string {
863
- return method.modes?.length ? `${method.id}:${method.modes.join("|")}` : method.id;
806
+ const operation = manifest.operations.find((operation) =>
807
+ operation.id === requestedOperation || operation.aliases?.includes(requestedOperation)
808
+ );
809
+ return operation ? { operation, runOperation: operation.id } : undefined;
864
810
  }
865
811
 
866
- function formatCapabilityRequirement(capability: { id: string; schemaHash?: string }): string {
867
- return capability.schemaHash ? `${capability.id}@${capability.schemaHash}` : capability.id;
812
+ function parseWorkspaceOperationId(value: string): { workspace: string; operation: string } | undefined {
813
+ const slash = value.indexOf("/");
814
+ if (slash <= 0 || slash === value.length - 1) return undefined;
815
+ return {
816
+ workspace: value.slice(0, slash),
817
+ operation: value.slice(slash + 1),
818
+ };
868
819
  }
869
820
 
870
821
  function parseOperationArgs(operation: RuntimeOperationDefinition, args: string[]): ParsedOperationInput {
@@ -1016,12 +967,12 @@ async function renderOperationResult(
1016
967
  console.log("No changes applied.");
1017
968
  return;
1018
969
  }
1019
- console.log(`resolved ${result.plan.workflow} -> ${String(result.snapshotId ?? "no workspace source")}`);
970
+ console.log(`resolved ${result.plan.workflow}`);
1020
971
  return;
1021
972
  }
1022
973
 
1023
974
  if (operation.createsWorkspace && isWorkspaceRecord(result)) {
1024
- console.log(`${result.name} ${result.resourceId}`);
975
+ console.log(result.name);
1025
976
  return;
1026
977
  }
1027
978
 
@@ -1036,16 +987,8 @@ async function renderOperationResult(
1036
987
  return;
1037
988
  }
1038
989
 
1039
- if (isRecord(result)) {
1040
- const metadata = isRecord(result.metadata) ? result.metadata : {};
1041
- if (typeof metadata.snapshotId === "string") {
1042
- console.log(metadata.snapshotId);
1043
- return;
1044
- }
1045
- }
1046
-
1047
990
  if (isWorkspaceRecord(result)) {
1048
- console.log(`${result.name} ${result.resourceId}`);
991
+ console.log(result.name);
1049
992
  return;
1050
993
  }
1051
994
 
@@ -1253,10 +1196,12 @@ async function runRuntimeOperation<T>(
1253
1196
  return;
1254
1197
  }
1255
1198
  if (isHostCapabilityRequestEvent(event)) {
1256
- presenter?.pause();
1199
+ const suspendPresenter = hostCapabilityNeedsTerminal(event);
1200
+ const logger = createHostCapabilityLogger(event, presenter);
1201
+ if (suspendPresenter) presenter?.pause();
1257
1202
  try {
1258
1203
  if (sendSession) {
1259
- await answerHostCapabilityRequestOverSession(sendSession, event);
1204
+ await answerHostCapabilityRequestOverSession(sendSession, event, { logger });
1260
1205
  } else if (respond) {
1261
1206
  await answerHostCapabilityRequestOverSession((message) => {
1262
1207
  if (isRecord(message) && message.type === "response") {
@@ -1264,12 +1209,12 @@ async function runRuntimeOperation<T>(
1264
1209
  if (id) return respond(id, "error" in message ? { error: message.error } : { result: message.result });
1265
1210
  }
1266
1211
  throw new Error(`Session response channel cannot send ${String(isRecord(message) ? message.type : typeof message)}`);
1267
- }, event);
1212
+ }, event, { logger });
1268
1213
  } else {
1269
- await answerHostCapabilityRequest(runtime, event);
1214
+ await answerHostCapabilityRequest(runtime, event, { logger });
1270
1215
  }
1271
1216
  } finally {
1272
- presenter?.resume();
1217
+ if (suspendPresenter) presenter?.resume();
1273
1218
  }
1274
1219
  return;
1275
1220
  }
@@ -1391,6 +1336,7 @@ type HostCapabilityRequestEvent = {
1391
1336
  type: "host.capability.request";
1392
1337
  requestId?: string;
1393
1338
  id?: string;
1339
+ nodePath?: string;
1394
1340
  capability: string;
1395
1341
  params: unknown;
1396
1342
  };
@@ -1399,6 +1345,15 @@ type HostRequestHandlingOptions = {
1399
1345
  quietOpen?: boolean;
1400
1346
  };
1401
1347
 
1348
+ type HostCapabilityLogOptions = {
1349
+ stream?: "stdout" | "stderr" | "info";
1350
+ label?: string;
1351
+ };
1352
+
1353
+ type HostCapabilityRequestHandlingOptions = {
1354
+ logger?: (data: string, options?: HostCapabilityLogOptions) => void;
1355
+ };
1356
+
1402
1357
  class UnsupportedHostCapabilityError extends Error {
1403
1358
  constructor(capability: string) {
1404
1359
  super(
@@ -1446,11 +1401,15 @@ async function answerHostRequestOverSession(
1446
1401
  }
1447
1402
  }
1448
1403
 
1449
- async function answerHostCapabilityRequest(runtime: RuntimeClient, event: HostCapabilityRequestEvent): Promise<void> {
1404
+ async function answerHostCapabilityRequest(
1405
+ runtime: RuntimeClient,
1406
+ event: HostCapabilityRequestEvent,
1407
+ options: HostCapabilityRequestHandlingOptions = {},
1408
+ ): Promise<void> {
1450
1409
  const requestId = event.requestId ?? event.id;
1451
1410
  if (!requestId) throw new Error(`Host capability request is missing requestId`);
1452
1411
  try {
1453
- const handled = await handleHostCapabilityRequest(event.capability, event.params);
1412
+ const handled = await handleHostCapabilityRequest(event.capability, event.params, options);
1454
1413
  await runtime.control.hostResponse(requestId, { result: handled.result });
1455
1414
  } catch (error) {
1456
1415
  await runtime.control.hostResponse(requestId, {
@@ -1462,11 +1421,12 @@ async function answerHostCapabilityRequest(runtime: RuntimeClient, event: HostCa
1462
1421
  async function answerHostCapabilityRequestOverSession(
1463
1422
  send: (message: unknown) => void | Promise<void>,
1464
1423
  event: HostCapabilityRequestEvent,
1424
+ options: HostCapabilityRequestHandlingOptions = {},
1465
1425
  ): Promise<void> {
1466
1426
  const id = event.id ?? event.requestId;
1467
1427
  if (!id) throw new Error(`Host capability request is missing id`);
1468
1428
  try {
1469
- const handled = await handleHostCapabilityRequest(event.capability, event.params);
1429
+ const handled = await handleHostCapabilityRequest(event.capability, event.params, options);
1470
1430
  await send({ type: "response", id, result: handled.result });
1471
1431
  if (handled.closed) reportHostCapabilityClosed(send, id, handled.closed);
1472
1432
  } catch (error) {
@@ -1512,6 +1472,34 @@ function hostRequestNeedsTerminal(event: HostRequestEvent): boolean {
1512
1472
  }
1513
1473
  }
1514
1474
 
1475
+ function hostCapabilityNeedsTerminal(event: HostCapabilityRequestEvent): boolean {
1476
+ switch (event.capability) {
1477
+ case "cmux.open":
1478
+ return false;
1479
+ default:
1480
+ return true;
1481
+ }
1482
+ }
1483
+
1484
+ function createHostCapabilityLogger(
1485
+ event: HostCapabilityRequestEvent,
1486
+ presenter: RunPresenter | undefined,
1487
+ ): (data: string, options?: HostCapabilityLogOptions) => void {
1488
+ return (data, options = {}) => {
1489
+ if (presenter) {
1490
+ presenter.render({
1491
+ type: "log.output",
1492
+ nodePath: event.nodePath ?? "runtime",
1493
+ stream: options.stream ?? "info",
1494
+ label: options.label ?? event.capability,
1495
+ data,
1496
+ });
1497
+ return;
1498
+ }
1499
+ console.error(data);
1500
+ };
1501
+ }
1502
+
1515
1503
  function isTrustedCaptureHostCommand(params: unknown): boolean {
1516
1504
  return process.env.RIGKIT_TRUST_HOST_COMMANDS === "1" &&
1517
1505
  isRecord(params) &&
@@ -1523,12 +1511,18 @@ type HandledHostCapability = {
1523
1511
  closed?: Promise<void>;
1524
1512
  };
1525
1513
 
1526
- async function handleHostCapabilityRequest(capability: string, params: unknown): Promise<HandledHostCapability> {
1514
+ async function handleHostCapabilityRequest(
1515
+ capability: string,
1516
+ params: unknown,
1517
+ options: HostCapabilityRequestHandlingOptions = {},
1518
+ ): Promise<HandledHostCapability> {
1527
1519
  const handler = CLI_HOST_CAPABILITY_HANDLERS.get(capability);
1528
1520
  if (!handler) {
1529
1521
  throw new UnsupportedHostCapabilityError(capability);
1530
1522
  }
1531
- return normalizeHostCapabilityResult(await handler.handle(params));
1523
+ return normalizeHostCapabilityResult(await handler.handle(params, {
1524
+ log: (data, logOptions) => options.logger?.(data, logOptions),
1525
+ }));
1532
1526
  }
1533
1527
 
1534
1528
  function normalizeHostCapabilityResult(value: unknown): HandledHostCapability {
@@ -1724,6 +1718,7 @@ function isHostCapabilityRequestEvent(value: unknown): value is HostCapabilityRe
1724
1718
  return isRecord(value) &&
1725
1719
  value.type === "host.capability.request" &&
1726
1720
  (typeof value.requestId === "string" || typeof value.id === "string") &&
1721
+ (value.nodePath === undefined || typeof value.nodePath === "string") &&
1727
1722
  typeof value.capability === "string";
1728
1723
  }
1729
1724
 
@@ -1738,8 +1733,8 @@ function isWorkflowPlan(value: unknown): value is WorkflowPlan {
1738
1733
  function isWorkspaceRecord(value: unknown): value is WorkspaceRecord {
1739
1734
  return isRecord(value) &&
1740
1735
  typeof value.name === "string" &&
1741
- typeof value.resourceId === "string" &&
1742
- typeof value.workflow === "string";
1736
+ typeof value.workflow === "string" &&
1737
+ isRecord(value.ctx);
1743
1738
  }
1744
1739
 
1745
1740
  function isDevMachineEvent(value: unknown): value is DevMachineEvent {
@@ -1791,7 +1786,7 @@ function printPlan(plan: WorkflowPlan): void {
1791
1786
  }
1792
1787
 
1793
1788
  function printWorkspaces(
1794
- workspaces: ReadonlyArray<Pick<WorkspaceRecord, "name" | "workflow" | "snapshotId" | "createdAt"> & { resourceId?: string }>,
1789
+ workspaces: ReadonlyArray<Pick<WorkspaceRecord, "name" | "workflow" | "createdAt">>,
1795
1790
  ): void {
1796
1791
  if (workspaces.length === 0) {
1797
1792
  console.log("No workspaces.");
@@ -1799,17 +1794,35 @@ function printWorkspaces(
1799
1794
  }
1800
1795
 
1801
1796
  printTable(
1802
- ["name", "resource", "snapshot", "workflow", "created"],
1797
+ ["name", "workflow", "created", "age"],
1803
1798
  workspaces.map((workspace) => [
1804
1799
  workspace.name,
1805
- workspace.resourceId ?? "",
1806
- workspace.snapshotId ?? "",
1807
1800
  workspace.workflow,
1808
1801
  workspace.createdAt,
1802
+ formatWorkspaceAge(workspace.createdAt),
1809
1803
  ]),
1810
1804
  );
1811
1805
  }
1812
1806
 
1807
+ function formatWorkspaceAge(createdAt: string): string {
1808
+ const createdTime = Date.parse(createdAt);
1809
+ if (Number.isNaN(createdTime)) return chalk.dim("unknown");
1810
+
1811
+ const ageMs = Math.max(0, Date.now() - createdTime);
1812
+ const minute = 60 * 1000;
1813
+ const hour = 60 * minute;
1814
+ const day = 24 * hour;
1815
+ const label = ageMs < hour
1816
+ ? `${Math.max(1, Math.floor(ageMs / minute))}m`
1817
+ : ageMs < day
1818
+ ? `${Math.floor(ageMs / hour)}h`
1819
+ : `${Math.floor(ageMs / day)}d`;
1820
+
1821
+ if (ageMs < day) return chalk.green(label);
1822
+ if (ageMs <= 3 * day) return chalk.yellow(label);
1823
+ return chalk.red(label);
1824
+ }
1825
+
1813
1826
  function printSnapshots(snapshots: SnapshotRecord[]): void {
1814
1827
  if (snapshots.length === 0) {
1815
1828
  console.log("No snapshots.");
@@ -1903,7 +1916,7 @@ function renderEvent(event: DevMachineEvent): void {
1903
1916
  console.error(`artifact ${event.providerId}:${event.kind}`);
1904
1917
  return;
1905
1918
  case "workspace.ready":
1906
- console.error(`workspace ${event.workspaceId} -> ${event.resourceId}`);
1919
+ console.error(`workspace ${event.workspaceId} ready`);
1907
1920
  return;
1908
1921
  default:
1909
1922
  return;
@@ -6,7 +6,7 @@ import { projectIdFor, runtimeFingerprintFor, runtimePaths, SUPPORTED_RUNTIME_AP
6
6
  import { completeRig, formatCompletionItems, renderCompletionScript } from "./completion.ts";
7
7
 
8
8
  describe("CLI completion", () => {
9
- test("completes ssh workspace targets from the runtime", async () => {
9
+ test("completes workspace targets from the runtime", async () => {
10
10
  const projectDir = mkdtempSync(join(tmpdir(), "rigkit-completion-"));
11
11
  await withWorkspaceRuntime({ projectDir }, async () => {
12
12
  const items = await completeRig({
@@ -16,11 +16,11 @@ describe("CLI completion", () => {
16
16
  });
17
17
 
18
18
  expect(items.map((item) => item.value)).toEqual(["api", "web"]);
19
- expect(items[0]?.description).toBe("vm-api");
19
+ expect(items[0]?.description).toBe("smoke");
20
20
  });
21
21
  });
22
22
 
23
- test("completes ssh resource ids when the current token starts like a resource id", async () => {
23
+ test("does not complete provider resource ids as workspace targets", async () => {
24
24
  const projectDir = mkdtempSync(join(tmpdir(), "rigkit-completion-"));
25
25
  await withWorkspaceRuntime({ projectDir }, async () => {
26
26
  const items = await completeRig({
@@ -29,7 +29,7 @@ describe("CLI completion", () => {
29
29
  currentIndex: 2,
30
30
  });
31
31
 
32
- expect(items.map((item) => item.value)).toEqual(["vm-api", "vm-web"]);
32
+ expect(items).toEqual([]);
33
33
  });
34
34
  });
35
35
 
@@ -47,6 +47,26 @@ describe("CLI completion", () => {
47
47
  });
48
48
  });
49
49
 
50
+ test("completes workspace operation targets", async () => {
51
+ const projectDir = mkdtempSync(join(tmpdir(), "rigkit-completion-"));
52
+ await withWorkspaceRuntime({ projectDir }, async () => {
53
+ const roots = await completeRig({
54
+ cwd: projectDir,
55
+ words: ["rig", "run", ""],
56
+ currentIndex: 2,
57
+ });
58
+ expect(roots.map((item) => item.value)).toContain("api/");
59
+ expect(roots.map((item) => item.value)).toContain("ssh");
60
+
61
+ const operations = await completeRig({
62
+ cwd: projectDir,
63
+ words: ["rig", "run", "api/"],
64
+ currentIndex: 2,
65
+ });
66
+ expect(operations.map((item) => item.value)).toEqual(["api/remove", "api/open-cmux"]);
67
+ });
68
+ });
69
+
50
70
  test("formats shell completion items", () => {
51
71
  const items = [{ value: "api", description: "vm-api" }];
52
72
 
@@ -110,26 +130,16 @@ async function withWorkspaceRuntime(
110
130
  {
111
131
  id: "workspace-api",
112
132
  name: "api",
113
- providerId: "freestyle",
114
133
  workflow: "smoke",
115
- resourceId: "vm-api",
116
- sourceRef: null,
117
- context: {},
118
- metadata: {},
119
- data: {},
134
+ ctx: {},
120
135
  createdAt: now,
121
136
  updatedAt: now,
122
137
  },
123
138
  {
124
139
  id: "workspace-web",
125
140
  name: "web",
126
- providerId: "freestyle",
127
141
  workflow: "smoke",
128
- resourceId: "vm-web",
129
- sourceRef: null,
130
- context: {},
131
- metadata: {},
132
- data: {},
142
+ ctx: {},
133
143
  createdAt: now,
134
144
  updatedAt: now,
135
145
  },
@@ -138,14 +148,6 @@ async function withWorkspaceRuntime(
138
148
  }
139
149
  if (pathname === "/operations") {
140
150
  return runtimeJson({
141
- hostMethods: {
142
- known: [],
143
- requiredByOperations: {},
144
- },
145
- hostCapabilities: {
146
- optional: [],
147
- requiredByOperations: {},
148
- },
149
151
  operations: [
150
152
  {
151
153
  id: "ssh",
@@ -166,6 +168,35 @@ async function withWorkspaceRuntime(
166
168
  },
167
169
  },
168
170
  ],
171
+ workspaceOperations: [
172
+ {
173
+ id: "remove",
174
+ kind: "workspace-action",
175
+ source: "core",
176
+ title: "Remove",
177
+ description: "remove workspace",
178
+ cli: {
179
+ options: [{ name: "yes", flag: "--yes", aliases: ["-y"], type: "boolean", runtime: false }],
180
+ },
181
+ inputSchema: {
182
+ type: "object",
183
+ additionalProperties: false,
184
+ properties: {},
185
+ },
186
+ },
187
+ {
188
+ id: "open-cmux",
189
+ kind: "workspace-action",
190
+ source: "config",
191
+ title: "Open cmux",
192
+ description: "open cmux",
193
+ inputSchema: {
194
+ type: "object",
195
+ additionalProperties: false,
196
+ properties: {},
197
+ },
198
+ },
199
+ ],
169
200
  });
170
201
  }
171
202
  return runtimeJson({ error: { message: "Not found" } }, { status: 404 });
package/src/completion.ts CHANGED
@@ -17,6 +17,7 @@ type CompleteRigInput = {
17
17
  const COMMANDS: CompletionItem[] = [
18
18
  { value: "help", description: "show CLI help" },
19
19
  { value: "init", description: "initialize a Rigkit project" },
20
+ { value: "run", description: "run a project operation" },
20
21
  { value: "ls", description: "list project workspaces" },
21
22
  { value: "projects", description: "discover Rigkit projects" },
22
23
  { value: "doctor", description: "show runtime diagnostics" },
@@ -77,6 +78,7 @@ const OPTIONS_WITH_VALUES = new Set([
77
78
 
78
79
  type RuntimeOperationManifest = {
79
80
  operations: RuntimeOperationDefinition[];
81
+ workspaceOperations?: RuntimeOperationDefinition[];
80
82
  };
81
83
 
82
84
  type RuntimeOperationDefinition = {
@@ -121,7 +123,12 @@ export async function completeRig(input: CompleteRigInput): Promise<CompletionIt
121
123
  }
122
124
  return [];
123
125
  }
124
- return filterItems(current.startsWith("-") ? GLOBAL_OPTIONS : [...COMMANDS, ...await safeOperationTargets(resolveProjectDir(words, cwd)), ...GLOBAL_OPTIONS], current);
126
+ return filterItems(
127
+ current.startsWith("-")
128
+ ? GLOBAL_OPTIONS
129
+ : [...COMMANDS, ...await safeOperationTargets(resolveProjectDir(words, cwd), current), ...GLOBAL_OPTIONS],
130
+ current,
131
+ );
125
132
  }
126
133
 
127
134
  if (current.startsWith("-")) {
@@ -147,7 +154,7 @@ export async function completeRig(input: CompleteRigInput): Promise<CompletionIt
147
154
  if (command === "run") {
148
155
  const run = parseRunCommand(before);
149
156
  if (!run.operation) {
150
- return filterItems(await safeOperationTargets(resolveProjectDir(words, cwd)), current);
157
+ return filterItems(await safeOperationTargets(resolveProjectDir(words, cwd), current), current);
151
158
  }
152
159
  const operation = await safeResolveRuntimeOperation(resolveProjectDir(words, cwd), run.operation);
153
160
  const operationPositionalCount = countRunOperationPositionals(run.args);
@@ -355,48 +362,65 @@ function projectPaths(projectDir: string): { projectDir: string; configPath: str
355
362
 
356
363
  async function workspaceTargets(
357
364
  paths: { projectDir: string; configPath: string },
358
- current: string,
359
- includeVmIds: boolean,
365
+ _current: string,
366
+ _includeVmIds: boolean,
360
367
  ): Promise<CompletionItem[]> {
361
368
  const workspaces = await readWorkspaces(paths);
362
369
  const items = workspaces.map((workspace) => ({
363
370
  value: workspace.name,
364
- description: workspace.resourceId,
371
+ description: workspace.workflow,
365
372
  }));
366
373
 
367
- if (includeVmIds && current.length > 0) {
368
- for (const workspace of workspaces) {
369
- if (!workspace.resourceId) continue;
370
- items.push({
371
- value: workspace.resourceId,
372
- description: workspace.name,
373
- });
374
- }
375
- }
376
-
377
374
  return dedupeItems(items);
378
375
  }
379
376
 
380
- async function readWorkspaces(paths: { projectDir: string; configPath: string }): Promise<Array<{ name: string; resourceId?: string }>> {
377
+ async function readWorkspaces(paths: { projectDir: string; configPath: string }): Promise<Array<{ name: string; workflow: string }>> {
381
378
  const runtime = await getOrStartRuntime(paths);
382
379
  const { workspaces } = await runtime.control.workspaces();
383
380
  return workspaces.map((workspace) => ({
384
381
  name: workspace.name,
385
- resourceId: workspace.resourceId,
382
+ workflow: workspace.workflow,
386
383
  }));
387
384
  }
388
385
 
389
- async function operationTargets(paths: { projectDir: string; configPath: string }): Promise<CompletionItem[]> {
386
+ async function operationTargets(
387
+ paths: { projectDir: string; configPath: string },
388
+ current: string,
389
+ ): Promise<CompletionItem[]> {
390
390
  const manifest = await readOperations(paths);
391
+ if (current.includes("/")) {
392
+ return workspaceOperationTargets(manifest, current);
393
+ }
394
+ const workspaces = await readWorkspaces(paths).catch(() => []);
391
395
  return manifest.operations.flatMap((operation) => [
392
396
  { value: operation.id, description: operation.description },
393
397
  ...(operation.aliases ?? []).map((alias) => ({ value: alias, description: operation.description })),
398
+ ]).concat(workspaces.map((workspace) => ({
399
+ value: `${workspace.name}/`,
400
+ description: `workspace ${workspace.workflow}`,
401
+ })));
402
+ }
403
+
404
+ function workspaceOperationTargets(manifest: RuntimeOperationManifest, current: string): CompletionItem[] {
405
+ const slash = current.indexOf("/");
406
+ if (slash < 0) return [];
407
+ const workspace = current.slice(0, slash);
408
+ if (!workspace) return [];
409
+ return (manifest.workspaceOperations ?? []).flatMap((operation) => [
410
+ { value: `${workspace}/${operation.id}`, description: operation.description ?? "workspace operation" },
411
+ ...(operation.aliases ?? []).map((alias) => ({
412
+ value: `${workspace}/${alias}`,
413
+ description: operation.description ?? "workspace operation",
414
+ })),
394
415
  ]);
395
416
  }
396
417
 
397
- async function safeOperationTargets(paths: { projectDir: string; configPath: string }): Promise<CompletionItem[]> {
418
+ async function safeOperationTargets(
419
+ paths: { projectDir: string; configPath: string },
420
+ current: string,
421
+ ): Promise<CompletionItem[]> {
398
422
  try {
399
- return await operationTargets(paths);
423
+ return await operationTargets(paths, current);
400
424
  } catch {
401
425
  return [];
402
426
  }
@@ -407,6 +431,12 @@ async function resolveRuntimeOperation(
407
431
  operationId: string,
408
432
  ): Promise<RuntimeOperationDefinition | undefined> {
409
433
  const manifest = await readOperations(paths);
434
+ const workspaceOperation = parseWorkspaceOperationId(operationId);
435
+ if (workspaceOperation) {
436
+ return (manifest.workspaceOperations ?? []).find((operation) =>
437
+ operation.id === workspaceOperation.operation || operation.aliases?.includes(workspaceOperation.operation)
438
+ );
439
+ }
410
440
  return manifest.operations.find((operation) =>
411
441
  operation.id === operationId || operation.aliases?.includes(operationId)
412
442
  );
@@ -428,6 +458,15 @@ async function readOperations(paths: { projectDir: string; configPath: string })
428
458
  return await runtime.control.operations() as unknown as RuntimeOperationManifest;
429
459
  }
430
460
 
461
+ function parseWorkspaceOperationId(value: string): { workspace: string; operation: string } | undefined {
462
+ const slash = value.indexOf("/");
463
+ if (slash <= 0 || slash === value.length - 1) return undefined;
464
+ return {
465
+ workspace: value.slice(0, slash),
466
+ operation: value.slice(slash + 1),
467
+ };
468
+ }
469
+
431
470
  function filterItems(items: CompletionItem[], current: string): CompletionItem[] {
432
471
  return dedupeItems(items).filter((item) => item.value.startsWith(current));
433
472
  }
package/src/init.test.ts CHANGED
@@ -3,7 +3,12 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { initProject, normalizeMachineName } from "./init.ts";
6
- import { FREESTYLE_PROVIDER_PACKAGE_NAME, PROJECT_PACKAGE_NAME } from "./project.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";
7
12
  import { RIGKIT_CLI_VERSION } from "./version.ts";
8
13
 
9
14
  describe("initProject", () => {
@@ -14,7 +19,6 @@ describe("initProject", () => {
14
19
  projectDir,
15
20
  configPath: join(projectDir, "rig.config.ts"),
16
21
  name: "Platform API",
17
- apiKey: "fs_test_123",
18
22
  });
19
23
 
20
24
  expect(result.name).toBe("platform-api");
@@ -22,16 +26,18 @@ describe("initProject", () => {
22
26
  expect(existsSync(projectDir)).toBe(true);
23
27
  expect(result.created).toEqual({
24
28
  config: true,
25
- env: true,
26
- envExample: true,
29
+ env: false,
30
+ envExample: false,
27
31
  gitignore: true,
28
32
  packageJson: true,
29
33
  });
30
34
 
31
35
  expect(readFileSync(join(projectDir, "rig.config.ts"), "utf8")).toContain('sequence("platform-api"');
32
36
  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");
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);
35
41
  expect(readFileSync(join(projectDir, ".gitignore"), "utf8")).toContain(".env\n.rigkit/\n");
36
42
 
37
43
  const pkg = JSON.parse(readFileSync(join(projectDir, "package.json"), "utf8"));
@@ -40,6 +46,7 @@ describe("initProject", () => {
40
46
  expect(pkg.scripts.apply).toBe("rig apply");
41
47
  expect(pkg.devDependencies[PROJECT_PACKAGE_NAME]).toBe(RIGKIT_CLI_VERSION);
42
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);
43
50
  });
44
51
 
45
52
  test("updates existing project files without replacing package metadata", () => {
package/src/init.ts CHANGED
@@ -1,13 +1,18 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { RIGKIT_CLI_VERSION } from "./version.ts";
4
- import { FREESTYLE_PROVIDER_PACKAGE_NAME, PROJECT_PACKAGE_NAME } from "./project.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";
5
10
 
6
11
  export type InitProjectInput = {
7
12
  projectDir: string;
8
13
  configPath: string;
9
14
  name: string;
10
- apiKey: string;
15
+ apiKey?: string;
11
16
  force?: boolean;
12
17
  };
13
18
 
@@ -47,11 +52,14 @@ export function initProject(input: InitProjectInput): InitProjectResult {
47
52
  writeFileSync(input.configPath, starterConfig(name));
48
53
  }
49
54
 
55
+ const apiKey = input.apiKey?.trim();
50
56
  const envPath = join(input.projectDir, ".env");
51
- const env = writeEnvFile(envPath, input.apiKey);
57
+ const env = apiKey
58
+ ? writeEnvFile(envPath, apiKey)
59
+ : { created: false, updated: false };
52
60
 
53
61
  const envExamplePath = join(input.projectDir, ".env.example");
54
- const wroteEnvExample = !existsSync(envExamplePath);
62
+ const wroteEnvExample = Boolean(apiKey) && !existsSync(envExamplePath);
55
63
  if (wroteEnvExample) {
56
64
  writeFileSync(envExamplePath, "FREESTYLE_API_KEY=\n");
57
65
  }
@@ -94,32 +102,47 @@ export function normalizeMachineName(value: string): string {
94
102
  export function starterConfig(name: string): string {
95
103
  const workflowName = JSON.stringify(normalizeMachineName(name));
96
104
 
97
- return `import { defineConfig, env, sequence } from "@rigkit/sdk";
98
- import { freestyle } from "@rigkit/provider-freestyle";
105
+ return `import { defineConfig, sequence } from "@rigkit/sdk";
106
+ import { freestyle, VmBaseImage, VmSpec } from "@rigkit/provider-freestyle";
99
107
 
100
- const freestyleProvider = freestyle.provider({
101
- apiKey: () => env.secret("FREESTYLE_API_KEY"),
102
- image: "node-22",
103
- });
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();
104
114
 
105
115
  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}\`);
116
+ .step("verify-node-22", async ({ providers, step }) => {
117
+ const { vm, vmId } = await providers.freestyle.client.vms.create({
118
+ spec: vmSpec,
119
+ logger: step.log,
120
+ });
121
+ try {
122
+ const result = await vm.exec("node --version");
123
+ if ((result.statusCode ?? 0) !== 0 || !result.stdout.trim().startsWith("v22.")) {
124
+ throw new Error(\`Expected Node.js v22, got: \${result.stdout}\${result.stderr}\`);
125
+ }
126
+ const snapshot = await vm.snapshot();
127
+ return { ctx: { snapshotId: snapshot.snapshotId } };
128
+ } finally {
129
+ await providers.freestyle.client.vms.delete({ vmId });
111
130
  }
112
- return { vm: await vm.snapshotRef() };
113
131
  })
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
- };
132
+ .workspace({
133
+ create: async ({ workflow, providers, step }) => {
134
+ const { vmId } = await providers.freestyle.client.vms.create({
135
+ snapshotId: workflow.ctx.snapshotId,
136
+ idleTimeoutSeconds: vmIdleTimeoutSeconds,
137
+ logger: step.log,
138
+ });
139
+ return {
140
+ vmId,
141
+ };
142
+ },
143
+ remove: async ({ providers, workspace }) => {
144
+ await providers.freestyle.client.vms.delete({ vmId: workspace.ctx.vmId });
145
+ },
123
146
  });
124
147
 
125
148
  export default defineConfig({
@@ -217,11 +240,13 @@ function ensureProjectPackageJson(
217
240
  const devDependencies = isRecord(pkg.devDependencies) ? pkg.devDependencies : {};
218
241
  const sdkDependencyChanged =
219
242
  devDependencies[PROJECT_PACKAGE_NAME] !== RIGKIT_CLI_VERSION ||
220
- devDependencies[FREESTYLE_PROVIDER_PACKAGE_NAME] !== RIGKIT_CLI_VERSION;
243
+ devDependencies[FREESTYLE_PROVIDER_PACKAGE_NAME] !== RIGKIT_CLI_VERSION ||
244
+ devDependencies[FREESTYLE_SDK_PACKAGE_NAME] !== FREESTYLE_SDK_PACKAGE_VERSION;
221
245
  if (sdkDependencyChanged) {
222
246
  delete devDependencies["@rigkit/runtime"];
223
247
  devDependencies[PROJECT_PACKAGE_NAME] = RIGKIT_CLI_VERSION;
224
248
  devDependencies[FREESTYLE_PROVIDER_PACKAGE_NAME] = RIGKIT_CLI_VERSION;
249
+ devDependencies[FREESTYLE_SDK_PACKAGE_NAME] = FREESTYLE_SDK_PACKAGE_VERSION;
225
250
  updated = true;
226
251
  }
227
252
  pkg.devDependencies = sortObject(devDependencies);
package/src/project.ts CHANGED
@@ -4,6 +4,8 @@ import { existsSync, readdirSync } from "node:fs";
4
4
  export const DEFAULT_CONFIG_FILE = "rig.config.ts";
5
5
  export const PROJECT_PACKAGE_NAME = "@rigkit/sdk";
6
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";
7
9
 
8
10
  export type ConfigPathOptions = {
9
11
  project?: string;
@@ -146,7 +146,7 @@ export function createRunPresenter(operation: string): RunPresenter | undefined
146
146
  );
147
147
  break;
148
148
  case "workspace.ready":
149
- phase = `Workspace ${String(event.resourceId ?? event.workspaceId ?? "ready")} ready`;
149
+ phase = `Workspace ${String(event.workspaceId ?? "ready")} ready`;
150
150
  break;
151
151
  case "run.completed":
152
152
  finalStatus = "completed";
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const RIGKIT_CLI_VERSION = "0.2.2";
1
+ export const RIGKIT_CLI_VERSION = "0.2.4";