@whyvix/remote 0.3.0 → 0.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whyvix/remote",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "files": [
@@ -5,14 +5,16 @@ import { envelope, parseMessage, TYPES } from "../../protocol/src/index.js";
5
5
  import { createSecurity } from "./security.js";
6
6
  import { ProcessManager } from "./process-manager.js";
7
7
  import { RemoteExecutionManager } from "./remote-execution-manager.js";
8
+ import { DeploymentManager } from "./deployment-manager.js";
8
9
  import { createToolExecutor } from "./tools.js";
9
10
 
10
- export function createAgent({gatewayUrl="ws://127.0.0.1:8787",deviceId=randomUUID(),deviceName=os.hostname(),token="",workspaces=[process.cwd()],remoteTargets=[],reconnectMs=1000,reconnect=true,audit=()=>{},log=console.log,errorLog=console.error,onRegistered=()=>{}}={}) {
11
+ export function createAgent({gatewayUrl="ws://127.0.0.1:8787",deviceId=randomUUID(),deviceName=os.hostname(),token="",workspaces=[process.cwd()],remoteTargets=[],deploymentProfiles=[],agentVersion="unknown",capabilities=[],reconnectMs=1000,reconnect=true,audit=()=>{},log=console.log,errorLog=console.error,onRegistered=()=>{}}={}) {
11
12
  let ws=null,reconnectTimer=null,stopped=false,generation=0;
12
13
  const security=createSecurity({token,workspaces,audit});
13
14
  const processManager=new ProcessManager({audit});
14
15
  const remoteExecutionManager=new RemoteExecutionManager({targets:remoteTargets,audit});
15
- const executeTool=createToolExecutor({security,processManager,remoteExecutionManager});
16
+ const deploymentManager=new DeploymentManager({profiles:deploymentProfiles,security,audit});
17
+ const executeTool=createToolExecutor({security,processManager,remoteExecutionManager,deploymentManager});
16
18
  function scheduleReconnect(reason){
17
19
  if(stopped||!reconnect||reconnectTimer)return;
18
20
  log("[agent:reconnect-scheduled]",reason,reconnectMs);
@@ -24,8 +26,8 @@ export function createAgent({gatewayUrl="ws://127.0.0.1:8787",deviceId=randomUUI
24
26
  const current=++generation;
25
27
  const socket=new WebSocket(gatewayUrl); ws=socket;
26
28
  log("[agent:connecting]",gatewayUrl,"generation="+current);
27
- socket.on("open",()=>{if(ws!==socket)return;log("[agent:connected]",gatewayUrl);socket.send(JSON.stringify(envelope(TYPES.REGISTER,{auth:{token},device:{id:deviceId,name:deviceName,platform:process.platform,agentVersion:"0.3.0"}})));});
28
- socket.on("message",async(raw)=>{if(ws!==socket)return;try{const m=parseMessage(raw);if(m.type===TYPES.REGISTERED){log("[agent:registered]",m.deviceId);onRegistered({deviceId:m.deviceId,authorizedAccount:m.authorizedAccount||""});return;}if(m.type!==TYPES.REQUEST)return;log("[tool:request]",m.tool);try{const result=await executeTool(m.tool,m.arguments);if(m.tool==="device.ping")Object.assign(result,{deviceId,deviceName});log("[tool:success]",m.tool);if(socket.readyState===WebSocket.OPEN)socket.send(JSON.stringify(envelope(TYPES.RESPONSE,{requestId:m.requestId,result})));}catch(error){errorLog("[tool:rejected]",m.tool,error.code||"TOOL_ERROR",error.message);if(socket.readyState===WebSocket.OPEN)socket.send(JSON.stringify(envelope(TYPES.ERROR,{requestId:m.requestId,error:{code:error.code||"TOOL_ERROR",message:error.message}})));}}catch(error){errorLog("[agent:protocol-error]",error.message);}});
29
+ socket.on("open",()=>{if(ws!==socket)return;log("[agent:connected]",gatewayUrl);socket.send(JSON.stringify(envelope(TYPES.REGISTER,{auth:{token},device:{id:deviceId,name:deviceName,platform:process.platform,agentVersion,capabilities:[...new Set(capabilities.map(String))].sort()}})));});
30
+ socket.on("message",async(raw)=>{if(ws!==socket)return;try{const m=parseMessage(raw);if(m.type===TYPES.REGISTERED){log("[agent:registered]",m.deviceId);onRegistered({deviceId:m.deviceId,authorizedAccount:m.authorizedAccount||""});return;}if(m.type!==TYPES.REQUEST)return;log("[tool:request]",m.tool,{path:m.arguments?.path,cwd:m.arguments?.cwd});try{const result=await executeTool(m.tool,m.arguments);if(m.tool==="device.ping")Object.assign(result,{deviceId,deviceName});log("[tool:success]",m.tool);if(socket.readyState===WebSocket.OPEN)socket.send(JSON.stringify(envelope(TYPES.RESPONSE,{requestId:m.requestId,result})));}catch(error){errorLog("[tool:rejected]",m.tool,error.code||"TOOL_ERROR",error.message);if(socket.readyState===WebSocket.OPEN)socket.send(JSON.stringify(envelope(TYPES.ERROR,{requestId:m.requestId,error:{code:error.code||"TOOL_ERROR",message:error.message}})));}}catch(error){errorLog("[agent:protocol-error]",error.message);}});
29
31
  socket.on("close",(code)=>{log("[agent:disconnected]","generation="+current,"code="+code);if(ws===socket)ws=null;if(code===1008){errorLog("[agent:authorization-required] device credential rejected");return;}if(code===4001){errorLog("[agent:replaced] newer WHYVIX Remote connection is active");return;}scheduleReconnect("close");});
30
32
  socket.on("error",(e)=>{errorLog("[agent:socket-error]",e.message);scheduleReconnect("error");});
31
33
  }
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
3
3
  import crypto from "node:crypto";
4
4
  import { execFile } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
+ import { buildDeploymentOperation } from "./deployment-protocol.js";
6
7
 
7
8
  const execFileAsync=promisify(execFile);
8
9
  const PROFILE_RE=/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
@@ -48,4 +49,18 @@ export class DeploymentManager{
48
49
  this.audit({event:"deploy.prepare_local",profile:profile.alias,commit,dependencyChanged});
49
50
  return{profile:profile.alias,remoteAlias:profile.remoteAlias,commit,shortSha,artifact,artifactSha256,dependencyChanged};
50
51
  }
52
+ async stageArtifact({profile:profileAlias,commit,artifact,artifactSha256}){
53
+ const profile=this.#profile(profileAlias);
54
+ const operation=buildDeploymentOperation(profile,{operation:"stage",commit,artifactSha256});
55
+ const localArtifact=this.security.resolveAllowed(artifact);
56
+ const stat=await fs.stat(localArtifact);
57
+ if(!stat.isFile())fail("DEPLOY_TRANSFER_FAILED","Deployment artifact must be a regular file");
58
+ const actualSha256=await sha256(localArtifact);
59
+ if(actualSha256!==artifactSha256)fail("DEPLOY_CHECKSUM_MISMATCH","Local deployment artifact checksum changed");
60
+ try{
61
+ await this.execFileFn("scp",["--",localArtifact,`${operation.remoteAlias}:${operation.artifactPath}`],{windowsHide:true,maxBuffer:1024*1024});
62
+ }catch{fail("DEPLOY_TRANSFER_FAILED","Deployment artifact transfer failed");}
63
+ this.audit({event:"deploy.stage",profile:profile.alias,commit,artifactSha256});
64
+ return{profile:profile.alias,remoteAlias:operation.remoteAlias,commit,releaseId:operation.releaseId,artifactPath:operation.artifactPath,artifactSha256};
65
+ }
51
66
  }
@@ -0,0 +1,22 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ function fail(code,message){throw Object.assign(new Error(message),{code});}
5
+
6
+ export async function loadDeploymentProfiles(filePath){
7
+ const requested=String(filePath||"").trim();
8
+ if(!requested)return[];
9
+ const resolved=path.resolve(requested);
10
+ let parsed;
11
+ try{parsed=JSON.parse(await fs.readFile(resolved,"utf8"));}catch(error){
12
+ if(error instanceof SyntaxError)fail("DEPLOY_PROFILE_DENIED","Deployment profile file is not valid JSON");
13
+ fail("DEPLOY_PROFILE_DENIED","Deployment profile file cannot be read");
14
+ }
15
+ if(!Array.isArray(parsed))fail("DEPLOY_PROFILE_DENIED","Deployment profile file must contain an array");
16
+ return parsed.map((profile)=>{
17
+ if(!profile||typeof profile!=="object"||Array.isArray(profile))fail("DEPLOY_PROFILE_DENIED","Invalid deployment profile");
18
+ const allowed=new Set(["alias","label","remoteAlias","repository","remoteReleaseRoot","remoteStagingRoot","serviceName"]);
19
+ for(const key of Object.keys(profile))if(!allowed.has(key))fail("DEPLOY_PROFILE_DENIED","Unsupported deployment profile field");
20
+ return{alias:profile.alias,label:profile.label||"",remoteAlias:profile.remoteAlias,repository:profile.repository,...(profile.remoteReleaseRoot!==undefined?{remoteReleaseRoot:profile.remoteReleaseRoot}:{}),...(profile.remoteStagingRoot!==undefined?{remoteStagingRoot:profile.remoteStagingRoot}:{}),...(profile.serviceName!==undefined?{serviceName:profile.serviceName}:{})};
21
+ });
22
+ }
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import path from "node:path";
2
3
 
3
4
  const DEFAULT_EXECUTABLES = Object.freeze({
4
5
  node: process.execPath,
@@ -35,8 +36,8 @@ function resolveExecutable(registry, requested) {
35
36
  }
36
37
 
37
38
  export class ProcessManager {
38
- constructor({ audit=()=>{}, spawnFn=spawn, allowedExecutables=DEFAULT_EXECUTABLES, maxOutputBytes=262144, maxRetainedJobs=100, maxRunningJobs=16, jobRetentionMs=300000, now=()=>Date.now() }={}) {
39
- this.audit=audit; this.spawnFn=spawnFn; this.executableRegistry=normalizeRegistry(allowedExecutables);
39
+ constructor({ audit=()=>{}, spawnFn=spawn, allowedExecutables=DEFAULT_EXECUTABLES, platform=process.platform, nodeExecPath=process.execPath, maxOutputBytes=262144, maxRetainedJobs=100, maxRunningJobs=16, jobRetentionMs=300000, now=()=>Date.now() }={}) {
40
+ this.audit=audit; this.spawnFn=spawnFn; this.executableRegistry=normalizeRegistry(allowedExecutables); this.platform=platform; this.nodeExecPath=nodeExecPath;
40
41
  this.maxOutputBytes=Math.max(1024,Math.min(Number(maxOutputBytes)||262144,1048576));
41
42
  this.maxRetainedJobs=Math.max(1,Math.min(Number(maxRetainedJobs)||100,1000));
42
43
  this.maxRunningJobs=Math.max(1,Math.min(Number(maxRunningJobs)||16,128));
@@ -59,9 +60,14 @@ export class ProcessManager {
59
60
  if(args.some(x=>/^--(?:yes|package|call|shell|install)/i.test(x))) fail("LOCAL_EXEC_NOT_ALLOWED","npx install or shell options are not allowed");
60
61
  args=["--no-install",...args];
61
62
  }
63
+ let spawnCommand=executable.command;
64
+ if(this.platform==="win32"&&(executable.name==="npm"||executable.name==="npx")) {
65
+ const cli=path.join(path.dirname(this.nodeExecPath),"node_modules","npm","bin",executable.name==="npm"?"npm-cli.js":"npx-cli.js");
66
+ spawnCommand=this.nodeExecPath; args=[cli,...args];
67
+ }
62
68
  if([...this.processes.values()].filter(x=>x.running).length>=this.maxRunningJobs)fail("LOCAL_JOB_LIMIT","Local running job limit reached");
63
69
  const id=String(this.nextId++);let child;
64
- try{child=this.spawnFn(executable.command,args,{cwd,windowsHide:true,shell:false});}catch{fail("LOCAL_EXEC_FAILED","Local process failed to start");}
70
+ try{child=this.spawnFn(spawnCommand,args,{cwd,windowsHide:true,shell:false});}catch{fail("LOCAL_EXEC_FAILED","Local process failed to start");}
65
71
  const state={id,ownerId:ownerId==null?null:String(ownerId),command:executable.name,cwd,stdout:"",stderr:"",truncated:false,exitCode:null,errorCode:null,timedOut:false,running:true,completedAt:null,child};
66
72
  this.processes.set(id,state);this.audit({event:"process.start",id,command:executable.name,cwd});
67
73
  child.stdout?.on("data",x=>appendBounded(state,"stdout",x,this.maxOutputBytes));child.stderr?.on("data",x=>appendBounded(state,"stderr",x,this.maxOutputBytes));
@@ -5,7 +5,7 @@ import { execFile } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
6
  const execFileAsync=promisify(execFile);
7
7
 
8
- export function createToolExecutor({ security, processManager, remoteExecutionManager }) {
8
+ export function createToolExecutor({ security, processManager, remoteExecutionManager, deploymentManager }) {
9
9
  return async function executeTool(tool,args={}) {
10
10
  security.audit({event:"tool.request",tool});
11
11
  if(tool==="device.ping") return {pong:true,timestamp:new Date().toISOString()};
@@ -70,18 +70,20 @@ export function createToolExecutor({ security, processManager, remoteExecutionMa
70
70
  await fs.writeFile(target,updated,{encoding:"utf8",flag:"w"});
71
71
  return{path:target,bytes,replacements:1};
72
72
  }
73
+ if(tool==="deploy.prepare_local"){if(!deploymentManager)throw Object.assign(new Error("Deployment is not configured"),{code:"DEPLOY_PROFILE_DENIED"});return deploymentManager.prepareLocal({profile:args.profile,commit:args.commit,baseCommit:args.baseCommit??null});}
73
74
  if(tool==="remote.targets"){if(!remoteExecutionManager)throw Object.assign(new Error("Remote execution is not configured"),{code:"REMOTE_TARGET_DENIED"});return{targets:remoteExecutionManager.listTargets()};}
74
75
  if(tool==="remote.start"){if(!remoteExecutionManager)throw Object.assign(new Error("Remote execution is not configured"),{code:"REMOTE_TARGET_DENIED"});return remoteExecutionManager.start(args);}
76
+ if(tool==="remote.deploy_status"){if(!remoteExecutionManager)throw Object.assign(new Error("Remote execution is not configured"),{code:"REMOTE_TARGET_DENIED"});return remoteExecutionManager.startDeployHelper({alias:args.alias,input:{operation:"status",commit:args.commit},timeoutMs:args.timeoutMs,ownerId:args.ownerId});}
75
77
  if(tool==="remote.output"){if(!remoteExecutionManager)throw Object.assign(new Error("Remote execution is not configured"),{code:"REMOTE_JOB_NOT_FOUND"});return remoteExecutionManager.output(args);}
76
78
  if(tool==="remote.stop"){if(!remoteExecutionManager)throw Object.assign(new Error("Remote execution is not configured"),{code:"REMOTE_JOB_NOT_FOUND"});return remoteExecutionManager.stop(args);}
77
- if(tool==="process.start"){const cwd=security.resolveAllowed(args.cwd);return processManager.start({...args,cwd});}
79
+ if(tool==="process.start"){const cwd=security.resolveFilesystemPath(args.cwd);return processManager.start({...args,cwd});}
78
80
  if(tool==="process.output")return processManager.output(args);
79
81
  if(tool==="process.stop")return processManager.stop(args);
80
- if(tool==="git.status"||tool==="git.diff"||tool==="git.log"){const cwd=security.resolveAllowed(args.cwd);const gitArgs=tool==="git.status"?["status","--porcelain=v1","--branch"]:tool==="git.diff"?["diff","--",...(args.paths||[])]:["log","--oneline","-n",String(Math.min(Number(args.limit||10),50))];const {stdout,stderr}=await execFileAsync("git",gitArgs,{cwd,windowsHide:true,maxBuffer:1024*1024});return{stdout,stderr};}
81
- if(tool==="git.stage_paths"||tool==="git.commit"){
82
- const cwd=security.resolveAllowed(args.cwd);
82
+ if(tool==="git.status"||tool==="git.diff"||tool==="git.log"){const cwd=security.resolveFilesystemPath(args.cwd);const gitArgs=tool==="git.status"?["status","--porcelain=v1","--branch"]:tool==="git.diff"?["diff","--",...(args.paths||[])]:["log","--oneline","-n",String(Math.min(Number(args.limit||10),50))];const {stdout,stderr}=await execFileAsync("git",gitArgs,{cwd,windowsHide:true,maxBuffer:1024*1024});return{stdout,stderr};}
83
+ if(tool==="git.stage_paths"||tool==="git.commit"||tool==="git.push"){
84
+ const cwd=security.resolveFilesystemPath(args.cwd);
83
85
  const {stdout:rootRaw}=await execFileAsync("git",["rev-parse","--show-toplevel"],{cwd,windowsHide:true,maxBuffer:1024*1024});
84
- const repoRoot=security.resolveAllowed(rootRaw.trim());
86
+ const repoRoot=security.resolveFilesystemPath(rootRaw.trim());
85
87
  if(tool==="git.stage_paths"){
86
88
  const {stdout:alreadyStaged}=await execFileAsync("git",["diff","--cached","--name-only"],{cwd:repoRoot,windowsHide:true,maxBuffer:1024*1024});
87
89
  if(alreadyStaged.trim())throw Object.assign(new Error("Repository already has staged changes"),{code:"INDEX_NOT_EMPTY"});
@@ -89,7 +91,7 @@ export function createToolExecutor({ security, processManager, remoteExecutionMa
89
91
  if(paths.length<1||paths.length>64)throw Object.assign(new Error("paths must contain 1 to 64 explicit paths"),{code:"INVALID_ARGUMENT"});
90
92
  const relativePaths=paths.map((item)=>{
91
93
  if(!item||item==="."||item==="*"||item.startsWith("-"))throw Object.assign(new Error("Only explicit repository paths may be staged"),{code:"INVALID_ARGUMENT"});
92
- const absolute=security.resolveAllowed(path.resolve(repoRoot,item));
94
+ const absolute=security.resolveFilesystemPath(path.resolve(repoRoot,item));
93
95
  const relative=path.relative(repoRoot,absolute);
94
96
  if(!relative||relative.startsWith(".."+path.sep)||path.isAbsolute(relative))throw Object.assign(new Error("Path outside repository"),{code:"PATH_DENIED"});
95
97
  return relative.split(path.sep).join("/");
@@ -98,6 +100,11 @@ export function createToolExecutor({ security, processManager, remoteExecutionMa
98
100
  const {stdout}=await execFileAsync("git",["diff","--cached","--name-only","--",...relativePaths],{cwd:repoRoot,windowsHide:true,maxBuffer:1024*1024});
99
101
  return{staged:stdout.split(/\r?\n/).filter(Boolean)};
100
102
  }
103
+ if(tool==="git.push"){
104
+ const {stdout,stderr}=await execFileAsync("git",["push"],{cwd:repoRoot,windowsHide:true,maxBuffer:1024*1024});
105
+ const {stdout:sha}=await execFileAsync("git",["rev-parse","HEAD"],{cwd:repoRoot,windowsHide:true,maxBuffer:1024*1024});
106
+ return{sha:sha.trim(),stdout,stderr};
107
+ }
101
108
  const message=String(args.message||"");
102
109
  if(!message.trim()||message.length>500||/[\r\n]/.test(message))throw Object.assign(new Error("Commit message must be 1 to 500 characters on one line"),{code:"INVALID_ARGUMENT"});
103
110
  const {stdout:before}=await execFileAsync("git",["diff","--cached","--name-only"],{cwd:repoRoot,windowsHide:true,maxBuffer:1024*1024});
@@ -2,16 +2,22 @@
2
2
  import os from "node:os";
3
3
  import crypto from "node:crypto";
4
4
  import { mkdir, readFile, writeFile } from "node:fs/promises";
5
- import { join } from "node:path";
6
- import { createAgent } from "../agent/src/agent.js";
5
+ import { join, dirname } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { createAgent } from "../agent/src/agent.js";
8
+ import { loadDeploymentProfiles } from "../agent/src/deployment-profile-loader.js";
7
9
  import { openBrowser } from "./browser.js";
8
10
 
11
+ const packageRoot=join(dirname(fileURLToPath(import.meta.url)),"..","..");
12
+ const packageMeta=JSON.parse(await readFile(join(packageRoot,"package.json"),"utf8"));
9
13
  const command=process.argv[2]||"remote";
10
14
  if(command!=="remote"){console.error("Usage: whyvix-remote remote");process.exit(2);}
11
15
  const gatewayUrl=process.env.WHYVIX_GATEWAY_URL||"wss://remote.whyvix.com";
12
16
  const authUrl=(process.env.WHYVIX_AUTH_URL||"https://auth.whyvix.com").replace(/\/$/,"");
13
17
  const workspaces=(process.env.WHYVIX_WORKSPACES||process.cwd()).split(";").filter(Boolean);
14
- const remoteTargets=(process.env.WHYVIX_REMOTE_TARGETS||"").split(";").map(alias=>alias.trim()).filter(Boolean).map(alias=>({alias}));
18
+ const remoteTargets=(process.env.WHYVIX_REMOTE_TARGETS||"").split(";").map(alias=>alias.trim()).filter(Boolean).map(alias=>({alias}));
19
+ const deploymentProfiles=await loadDeploymentProfiles(process.env.WHYVIX_DEPLOY_PROFILES_FILE||"");
20
+ const capabilities=["filesystem.crud","process.bounded","git.read","git.mutate",...(remoteTargets.length?["remote.bounded"]:[]),...(deploymentProfiles.length?["deployment.profiled"]:[])];
15
21
  const deviceId=process.env.WHYVIX_DEVICE_ID||crypto.createHash("sha256").update(os.hostname()+"|"+os.userInfo().username).digest("hex").slice(0,24);
16
22
  const credentialDir=join(os.homedir(),".whyvix-remote");
17
23
  const credentialPath=join(credentialDir,"device.json");
@@ -56,10 +62,21 @@ const agent=createAgent({
56
62
  token:credential,
57
63
  workspaces,
58
64
  remoteTargets,
59
- audit:()=>{},
60
- log:(event,tool)=>{
61
- if(event==="[tool:request]") console.log("←",tool);
62
- else if(event==="[tool:success]") console.log("✓",tool);
65
+ deploymentProfiles,
66
+ agentVersion:String(packageMeta.version||"unknown"),
67
+ capabilities,
68
+ audit:(event)=>{
69
+ if(event?.event==="process.start") console.log("RUN",event.command,event.cwd);
70
+ else if(event?.event==="process.exit") console.log("EXIT",event.code,event.errorCode||"OK");
71
+ else if(event?.event==="process.error") console.error("PROCESS ERROR",event.code);
72
+ else if(event?.event==="remote.start") console.log("REMOTE",event.alias,event.executable);
73
+ else if(event?.event==="remote.exit") console.log("REMOTE EXIT",event.code,event.errorCode||"OK");
74
+ else if(event?.event==="deploy.prepare_local"||event?.event==="deploy.stage") console.log("DEPLOY",event.event,event.profile,event.commit);
75
+ },
76
+ log:(event,tool,meta)=>{
77
+ const quiet=tool==="process.output"||tool==="remote.output";
78
+ if(event==="[tool:request]"&&!quiet) console.log("←",tool,meta?.path||meta?.cwd||"");
79
+ else if(event==="[tool:success]"&&!quiet) console.log("✓",tool);
63
80
  },
64
81
  errorLog:(event,tool,code,message)=>{
65
82
  if(event==="[tool:rejected]") console.error("✗",tool,"·",code,"·",message);