@jive-ai/cli 0.0.45 → 0.0.47

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/dist/index.mjs CHANGED
@@ -28,9 +28,16 @@ import httpProxy from "http-proxy";
28
28
 
29
29
  //#region src/lib/config.ts
30
30
  /**
31
+ * Get the effective home directory - uses SUDO_USER's home when running with sudo
32
+ */
33
+ function getEffectiveHomeDir() {
34
+ if (process.env.SUDO_USER) return `/home/${process.env.SUDO_USER}`;
35
+ return os.homedir();
36
+ }
37
+ /**
31
38
  * `~/.jive/credentials.json`
32
39
  */
33
- const CREDENTIALS_PATH = path.join(os.homedir(), ".jive", "credentials.json");
40
+ const CREDENTIALS_PATH = path.join(getEffectiveHomeDir(), ".jive", "credentials.json");
34
41
  /**
35
42
  * `<cwd>/.jive/config.json`
36
43
  */
@@ -1023,6 +1030,48 @@ const mutations = {
1023
1030
  }
1024
1031
  }
1025
1032
  }
1033
+ `),
1034
+ CreateIssue: graphql(`
1035
+ mutation CreateIssue($input: CreateIssueInput!) {
1036
+ createIssue(input: $input) {
1037
+ success
1038
+ issue {
1039
+ id
1040
+ number
1041
+ title
1042
+ htmlUrl
1043
+ labels {
1044
+ name
1045
+ color
1046
+ }
1047
+ }
1048
+ errors {
1049
+ message
1050
+ code
1051
+ }
1052
+ }
1053
+ }
1054
+ `),
1055
+ UpdateIssue: graphql(`
1056
+ mutation UpdateIssue($input: UpdateIssueInput!) {
1057
+ updateIssue(input: $input) {
1058
+ success
1059
+ issue {
1060
+ id
1061
+ number
1062
+ title
1063
+ htmlUrl
1064
+ labels {
1065
+ name
1066
+ color
1067
+ }
1068
+ }
1069
+ errors {
1070
+ message
1071
+ code
1072
+ }
1073
+ }
1074
+ }
1026
1075
  `),
1027
1076
  RegisterRunner: graphql(`
1028
1077
  mutation RegisterRunner($input: RegisterRunnerInput!) {
@@ -1563,6 +1612,46 @@ var ApiClient = class {
1563
1612
  }
1564
1613
  };
1565
1614
  }
1615
+ async createIssue(projectId, data) {
1616
+ const result = await (await getGraphQLClient()).request(mutations.CreateIssue, { input: {
1617
+ projectId: String(projectId),
1618
+ title: data.title,
1619
+ body: data.body,
1620
+ labels: data.labels
1621
+ } });
1622
+ if (result.createIssue.errors?.length) throw this.formatError({ message: result.createIssue.errors[0].message });
1623
+ return {
1624
+ success: result.createIssue.success,
1625
+ issue: {
1626
+ id: result.createIssue.issue.id,
1627
+ number: result.createIssue.issue.number,
1628
+ title: result.createIssue.issue.title,
1629
+ htmlUrl: result.createIssue.issue.htmlUrl,
1630
+ labels: result.createIssue.issue.labels
1631
+ }
1632
+ };
1633
+ }
1634
+ async updateIssue(projectId, data) {
1635
+ const result = await (await getGraphQLClient()).request(mutations.UpdateIssue, { input: {
1636
+ projectId: String(projectId),
1637
+ number: data.number,
1638
+ title: data.title,
1639
+ body: data.body,
1640
+ state: data.state,
1641
+ labels: data.labels
1642
+ } });
1643
+ if (result.updateIssue.errors?.length) throw this.formatError({ message: result.updateIssue.errors[0].message });
1644
+ return {
1645
+ success: result.updateIssue.success,
1646
+ issue: {
1647
+ id: result.updateIssue.issue.id,
1648
+ number: result.updateIssue.issue.number,
1649
+ title: result.updateIssue.issue.title,
1650
+ htmlUrl: result.updateIssue.issue.htmlUrl,
1651
+ labels: result.updateIssue.issue.labels
1652
+ }
1653
+ };
1654
+ }
1566
1655
  async getTaskSteps(id) {
1567
1656
  const data = await (await getGraphQLClient()).request(queries.GetPlan, { id: String(id) });
1568
1657
  if (!data.plan?.steps) return [];
@@ -1961,7 +2050,7 @@ async function createGraphQLClient() {
1961
2050
 
1962
2051
  //#endregion
1963
2052
  //#region package.json
1964
- var version = "0.0.45";
2053
+ var version = "0.0.47";
1965
2054
 
1966
2055
  //#endregion
1967
2056
  //#region src/runner/index.ts
@@ -2184,7 +2273,6 @@ var TaskRunner = class {
2184
2273
  const url = new URL(WS_URL);
2185
2274
  url.searchParams.set("apiKey", jiveApiKey);
2186
2275
  url.searchParams.set("runnerId", this.config.id.toString());
2187
- console.log(chalk.dim(`Connecting to ${url.toString()}...`));
2188
2276
  this.ws = new WebSocket(url.toString());
2189
2277
  this.ws.on("open", () => this.handleOpen());
2190
2278
  this.ws.on("message", (data) => this.handleMessage(data));
@@ -2419,17 +2507,10 @@ async function queryClaude(prompt, mcpServer, opts) {
2419
2507
  permissionMode: mapPermissionMode(permissionMode),
2420
2508
  ...betaFlags.length > 0 && { betas: betaFlags },
2421
2509
  canUseTool: async (toolName, input) => {
2422
- if (toolName === "AskUserQuestion") {
2423
- const result = await task.requestUserQuestions(input);
2424
- if (result) return {
2425
- behavior: "allow",
2426
- updatedInput: result
2427
- };
2428
- else return {
2429
- behavior: "deny",
2430
- message: "User did not answer questions in time"
2431
- };
2432
- }
2510
+ if (toolName === "AskUserQuestion") return {
2511
+ behavior: "deny",
2512
+ message: "AskUserQuestion is not allowed. Use the ask_user_question tool from the jive-tasks MCP server instead."
2513
+ };
2433
2514
  if (await task.requestToolPermission(toolName, input)) return {
2434
2515
  behavior: "allow",
2435
2516
  updatedInput: input
@@ -2439,6 +2520,11 @@ async function queryClaude(prompt, mcpServer, opts) {
2439
2520
  message: "User denied this action"
2440
2521
  };
2441
2522
  },
2523
+ systemPrompt: {
2524
+ type: "preset",
2525
+ preset: "claude_code",
2526
+ append: "When you want to use AskUserQuestion, use the ask_user_question tool from the jive-tasks MCP server instead. DO NOT USE THE AskUserQuestion tool directly."
2527
+ },
2442
2528
  stderr: (data) => {
2443
2529
  console.error(data.toString());
2444
2530
  }
@@ -2714,6 +2800,136 @@ function createTasksSdkServer(task) {
2714
2800
  isError: true
2715
2801
  };
2716
2802
  }
2803
+ }),
2804
+ tool("create_issue", "Create an issue on GitHub or GitLab for the current project. Use this to track bugs, feature requests, or other work items.", {
2805
+ title: z.string().describe("Issue title - a concise summary of the issue"),
2806
+ body: z.string().describe("Issue body/description - detailed explanation of the issue"),
2807
+ labels: z.array(z.string()).optional().describe("Optional labels to apply to the issue")
2808
+ }, async (args) => {
2809
+ if (!context) return {
2810
+ content: [{
2811
+ type: "text",
2812
+ text: "Error: Task context not initialized"
2813
+ }],
2814
+ isError: true
2815
+ };
2816
+ try {
2817
+ const apiClient$1 = getApiClient();
2818
+ const { project } = await apiClient$1.getTask(context.taskId);
2819
+ if (!project) return {
2820
+ content: [{
2821
+ type: "text",
2822
+ text: "Error: Project not found for this task"
2823
+ }],
2824
+ isError: true
2825
+ };
2826
+ task.debugLog(`Creating issue: ${args.title}`);
2827
+ const result = await apiClient$1.createIssue(project.id, {
2828
+ title: args.title,
2829
+ body: args.body,
2830
+ labels: args.labels
2831
+ });
2832
+ task.debugLog(`Issue created: ${JSON.stringify(result, null, 2)}`);
2833
+ return { content: [{
2834
+ type: "text",
2835
+ text: JSON.stringify({
2836
+ success: true,
2837
+ projectId: project.id,
2838
+ issue: result.issue
2839
+ }, null, 2)
2840
+ }] };
2841
+ } catch (error$1) {
2842
+ return {
2843
+ content: [{
2844
+ type: "text",
2845
+ text: `Error creating issue: ${error$1.message}`
2846
+ }],
2847
+ isError: true
2848
+ };
2849
+ }
2850
+ }),
2851
+ tool("update_issue", "Update an existing issue on GitHub or GitLab. Use this to modify issue title, body, state (open/closed), or labels.", {
2852
+ number: z.number().describe("Issue number to update"),
2853
+ title: z.string().optional().describe("New title for the issue"),
2854
+ body: z.string().optional().describe("New body/description for the issue"),
2855
+ state: z.enum(["open", "closed"]).optional().describe("Issue state - \"open\" or \"closed\""),
2856
+ labels: z.array(z.string()).optional().describe("Labels to set on the issue (replaces existing labels)")
2857
+ }, async (args) => {
2858
+ if (!context) return {
2859
+ content: [{
2860
+ type: "text",
2861
+ text: "Error: Task context not initialized"
2862
+ }],
2863
+ isError: true
2864
+ };
2865
+ try {
2866
+ const apiClient$1 = getApiClient();
2867
+ const { project } = await apiClient$1.getTask(context.taskId);
2868
+ if (!project) return {
2869
+ content: [{
2870
+ type: "text",
2871
+ text: "Error: Project not found for this task"
2872
+ }],
2873
+ isError: true
2874
+ };
2875
+ task.debugLog(`Updating issue #${args.number}`);
2876
+ const result = await apiClient$1.updateIssue(project.id, {
2877
+ number: args.number,
2878
+ title: args.title,
2879
+ body: args.body,
2880
+ state: args.state,
2881
+ labels: args.labels
2882
+ });
2883
+ task.debugLog(`Issue updated: ${JSON.stringify(result, null, 2)}`);
2884
+ return { content: [{
2885
+ type: "text",
2886
+ text: JSON.stringify({
2887
+ success: true,
2888
+ projectId: project.id,
2889
+ issue: result.issue
2890
+ }, null, 2)
2891
+ }] };
2892
+ } catch (error$1) {
2893
+ return {
2894
+ content: [{
2895
+ type: "text",
2896
+ text: `Error updating issue: ${error$1.message}`
2897
+ }],
2898
+ isError: true
2899
+ };
2900
+ }
2901
+ }),
2902
+ tool("ask_user_question", "Ask the user clarifying questions. Returns immediately - answers will be provided in a follow-up message.", { questions: z.array(z.object({
2903
+ question: z.string().describe("The question to ask"),
2904
+ header: z.string().describe("Short header/label for the question"),
2905
+ options: z.array(z.object({
2906
+ label: z.string(),
2907
+ description: z.string()
2908
+ })).describe("Options for the user to choose from"),
2909
+ multiSelect: z.boolean().describe("Allow multiple selections")
2910
+ })).describe("Questions to ask the user") }, async (args) => {
2911
+ if (!context) return {
2912
+ content: [{
2913
+ type: "text",
2914
+ text: "Error: Task context not initialized"
2915
+ }],
2916
+ isError: true
2917
+ };
2918
+ try {
2919
+ task.sendUserQuestionRequest(args.questions);
2920
+ return { content: [{
2921
+ type: "text",
2922
+ text: `Questions sent to user. Their answers will be provided in a follow-up message. You may continue with other work while waiting.`
2923
+ }] };
2924
+ } catch (error$1) {
2925
+ return {
2926
+ content: [{
2927
+ type: "text",
2928
+ text: `Error: ${error$1.message}`
2929
+ }],
2930
+ isError: true
2931
+ };
2932
+ }
2717
2933
  })
2718
2934
  ]
2719
2935
  });
@@ -2882,7 +3098,6 @@ var Task = class {
2882
3098
  heartbeatInterval = null;
2883
3099
  pendingAcks = /* @__PURE__ */ new Map();
2884
3100
  pendingPermissions = /* @__PURE__ */ new Map();
2885
- pendingQuestions = /* @__PURE__ */ new Map();
2886
3101
  tunnels = /* @__PURE__ */ new Map();
2887
3102
  proxies = /* @__PURE__ */ new Map();
2888
3103
  primaryTunnelHost = null;
@@ -3057,10 +3272,6 @@ var Task = class {
3057
3272
  this.handleToolPermissionResponse(inputMessage.payload);
3058
3273
  continue;
3059
3274
  }
3060
- if (inputMessage.type === "user_question_response") {
3061
- this.handleUserQuestionResponse(inputMessage.payload);
3062
- continue;
3063
- }
3064
3275
  if (this.status !== "idle") {
3065
3276
  this.debugLog(`Queueing message (status: ${this.status}): ${inputMessage.type}`);
3066
3277
  this.queuedMessages.push(inputMessage);
@@ -3115,43 +3326,21 @@ var Task = class {
3115
3326
  return approved;
3116
3327
  }
3117
3328
  /**
3118
- * Request user to answer questions. Returns promise that resolves to { questions, answers } or null.
3119
- * Auto-denies (returns null) after 60 seconds if no response received.
3329
+ * Send user questions to the server (non-blocking).
3330
+ * Answers will come back as a user message via promptSendToRunner.
3120
3331
  */
3121
- async requestUserQuestions(input) {
3332
+ sendUserQuestionRequest(questions) {
3122
3333
  const requestId = crypto.randomUUID();
3123
- const TIMEOUT_MS = 6e4 * 60 * 24;
3124
- const questionPromise = new Promise((resolve) => {
3125
- const timeout = setTimeout(() => {
3126
- this.debugLog(`User question request ${requestId} timed out (24h)`);
3127
- this.pendingQuestions.delete(requestId);
3128
- resolve(null);
3129
- }, TIMEOUT_MS);
3130
- this.pendingQuestions.set(requestId, {
3131
- resolve,
3132
- timeout
3133
- });
3134
- });
3135
3334
  this.sendToTaskRunner({
3136
3335
  type: "user_question_request",
3137
3336
  payload: {
3138
3337
  requestId,
3139
3338
  sessionId: this.ctx.sessionId,
3140
- questions: input.questions,
3141
- expiresAt: new Date(Date.now() + TIMEOUT_MS).toISOString()
3339
+ questions,
3340
+ expiresAt: new Date(Date.now() + 6e4 * 60 * 24).toISOString()
3142
3341
  }
3143
3342
  });
3144
- this.debugLog(`Waiting for user to answer ${input.questions.length} question(s)...`);
3145
- const answers = await questionPromise;
3146
- if (!answers) {
3147
- this.debugLog(`User did not answer questions in time`);
3148
- return null;
3149
- }
3150
- this.debugLog(`User answered questions`);
3151
- return {
3152
- questions: input.questions,
3153
- answers
3154
- };
3343
+ this.debugLog(`Sent ${questions.length} question(s) to user (requestId: ${requestId})`);
3155
3344
  }
3156
3345
  /**
3157
3346
  * Handle tool permission response from runner
@@ -3167,20 +3356,6 @@ var Task = class {
3167
3356
  pending.resolve(payload.approved);
3168
3357
  this.pendingPermissions.delete(payload.requestId);
3169
3358
  }
3170
- /**
3171
- * Handle user question response from runner
3172
- */
3173
- handleUserQuestionResponse(payload) {
3174
- const pending = this.pendingQuestions.get(payload.requestId);
3175
- if (!pending) {
3176
- this.debugLog(`Received question response for unknown request: ${payload.requestId}`);
3177
- return;
3178
- }
3179
- this.updateLastActivity();
3180
- clearTimeout(pending.timeout);
3181
- pending.resolve(payload.answers);
3182
- this.pendingQuestions.delete(payload.requestId);
3183
- }
3184
3359
  processMessage(inputMessage) {
3185
3360
  this.updateLastActivity();
3186
3361
  switch (inputMessage.type) {
@@ -3193,9 +3368,6 @@ var Task = class {
3193
3368
  case "tool_permission_response":
3194
3369
  this.handleToolPermissionResponse(inputMessage.payload);
3195
3370
  break;
3196
- case "user_question_response":
3197
- this.handleUserQuestionResponse(inputMessage.payload);
3198
- break;
3199
3371
  }
3200
3372
  }
3201
3373
  processQueuedMessages() {
@@ -3732,6 +3904,17 @@ Host gitlab.com
3732
3904
  this.claudeAbortController = null;
3733
3905
  this.sendStatusUpdate("idle");
3734
3906
  }
3907
+ reportUsage(message) {
3908
+ if (!("total_cost_usd" in message)) return;
3909
+ this.sendToTaskRunner({
3910
+ type: "usage",
3911
+ payload: { usage: {
3912
+ total_cost_usd: message.total_cost_usd,
3913
+ total_input_tokens: message.usage.input_tokens,
3914
+ total_output_tokens: message.usage.output_tokens
3915
+ } }
3916
+ });
3917
+ }
3735
3918
  async queryClaude(prompt, mode = "BYPASS_PERMISSIONS") {
3736
3919
  if (this.status !== "idle") {
3737
3920
  this.debugLog(`WARNING: queryClaude called while status is '${this.status}'`);
@@ -3750,7 +3933,7 @@ Host gitlab.com
3750
3933
  permissionMode: mode,
3751
3934
  task: this
3752
3935
  });
3753
- for await (const _message of result);
3936
+ for await (const message of result) if (message.type === "result") this.reportUsage(message);
3754
3937
  const finalLines = await this.readNewSessionLines();
3755
3938
  for (const line of finalLines) try {
3756
3939
  const parsed = JSON.parse(line);
@@ -4139,7 +4322,7 @@ async function installServiceCommand() {
4139
4322
  console.log(chalk.bold("\nJive Task Runner Service Installation"));
4140
4323
  console.log(chalk.dim("=========================================\n"));
4141
4324
  try {
4142
- const { getServiceManager, validateServiceInstallation, detectPlatform } = await import("./service-4H4YceKv.mjs");
4325
+ const { getServiceManager, validateServiceInstallation, detectPlatform } = await import("./service-MMjLsA9C.mjs");
4143
4326
  if (detectPlatform() === "unsupported") {
4144
4327
  console.error(chalk.red("Service installation is not supported on this platform."));
4145
4328
  console.log(chalk.dim("\nCurrently supported platforms:"));
@@ -4171,7 +4354,8 @@ async function installServiceCommand() {
4171
4354
  }
4172
4355
  if (validation.hasWarnings) console.log(chalk.yellow("\n⚠ Installation can proceed, but there are warnings."));
4173
4356
  console.log(chalk.dim(`\nPlatform: Linux (systemd)`));
4174
- console.log(chalk.dim(`Service file: ~/.config/systemd/user/jive-task-runner.service\n`));
4357
+ console.log(chalk.dim(`Service file: /etc/systemd/system/jive-task-runner.service`));
4358
+ console.log(chalk.dim(`Credentials: /etc/jive/runner.env\n`));
4175
4359
  const { confirm } = await prompts({
4176
4360
  type: "confirm",
4177
4361
  name: "confirm",
@@ -4182,8 +4366,9 @@ async function installServiceCommand() {
4182
4366
  console.log(chalk.yellow("Installation cancelled"));
4183
4367
  return;
4184
4368
  }
4185
- spinner.start("Creating service file...");
4186
4369
  const manager = getServiceManager();
4370
+ await manager.checkAndMigrateLegacyService();
4371
+ spinner.start("Creating service file...");
4187
4372
  await manager.install();
4188
4373
  spinner.succeed("Service installed successfully!");
4189
4374
  const { startNow } = await prompts({
@@ -4201,9 +4386,10 @@ async function installServiceCommand() {
4201
4386
  if (status.uptime) console.log(chalk.dim(`Uptime: ${status.uptime}`));
4202
4387
  }
4203
4388
  console.log(chalk.bold("\nNext steps:"));
4204
- console.log(chalk.dim(" • View logs: ") + chalk.cyan("jive task-runner service-logs"));
4205
- console.log(chalk.dim(" • Check status: ") + chalk.cyan("jive task-runner service-status"));
4206
- console.log(chalk.dim(" • Restart: ") + chalk.cyan("jive task-runner service-restart"));
4389
+ console.log(chalk.dim(" • View logs: ") + chalk.cyan("journalctl -u jive-task-runner -f"));
4390
+ console.log(chalk.dim(" • Check status: ") + chalk.cyan("systemctl status jive-task-runner"));
4391
+ console.log(chalk.dim(" • Restart: ") + chalk.cyan("sudo systemctl restart jive-task-runner"));
4392
+ console.log(chalk.dim(" • Uninstall: ") + chalk.cyan("jive task-runner uninstall-service"));
4207
4393
  } catch (error$1) {
4208
4394
  console.error(chalk.red(`\n✗ Installation failed: ${error$1.message}`));
4209
4395
  process.exit(1);
@@ -4214,7 +4400,7 @@ async function installServiceCommand() {
4214
4400
  */
4215
4401
  async function uninstallServiceCommand() {
4216
4402
  try {
4217
- const { getServiceManager } = await import("./service-4H4YceKv.mjs");
4403
+ const { getServiceManager } = await import("./service-MMjLsA9C.mjs");
4218
4404
  const manager = getServiceManager();
4219
4405
  if (!await manager.isInstalled()) {
4220
4406
  console.log(chalk.yellow("Service is not installed."));
@@ -4244,7 +4430,7 @@ async function uninstallServiceCommand() {
4244
4430
  */
4245
4431
  async function serviceStatusCommand() {
4246
4432
  try {
4247
- const { getServiceManager } = await import("./service-4H4YceKv.mjs");
4433
+ const { getServiceManager } = await import("./service-MMjLsA9C.mjs");
4248
4434
  const manager = getServiceManager();
4249
4435
  if (!await manager.isInstalled()) {
4250
4436
  console.log(chalk.dim("Service is not installed."));
@@ -4276,10 +4462,10 @@ async function serviceStatusCommand() {
4276
4462
  }
4277
4463
  console.log(chalk.bold("\nCommands:"));
4278
4464
  console.log(chalk.dim("─".repeat(60)));
4279
- console.log(chalk.dim(" Logs: ") + chalk.cyan("jive task-runner service-logs [-f]"));
4280
- console.log(chalk.dim(" Restart: ") + chalk.cyan("jive task-runner service-restart"));
4281
- if (status.running) console.log(chalk.dim(" Stop: ") + chalk.cyan("systemctl --user stop jive-task-runner"));
4282
- else console.log(chalk.dim(" Start: ") + chalk.cyan("systemctl --user start jive-task-runner"));
4465
+ console.log(chalk.dim(" Logs: ") + chalk.cyan("journalctl -u jive-task-runner -f"));
4466
+ console.log(chalk.dim(" Restart: ") + chalk.cyan("sudo systemctl restart jive-task-runner"));
4467
+ if (status.running) console.log(chalk.dim(" Stop: ") + chalk.cyan("sudo systemctl stop jive-task-runner"));
4468
+ else console.log(chalk.dim(" Start: ") + chalk.cyan("sudo systemctl start jive-task-runner"));
4283
4469
  console.log(chalk.dim(" Uninstall: ") + chalk.cyan("jive task-runner uninstall-service"));
4284
4470
  console.log();
4285
4471
  } catch (error$1) {
@@ -4292,7 +4478,7 @@ async function serviceStatusCommand() {
4292
4478
  */
4293
4479
  async function serviceLogsCommand(options) {
4294
4480
  try {
4295
- const { getServiceManager } = await import("./service-4H4YceKv.mjs");
4481
+ const { getServiceManager } = await import("./service-MMjLsA9C.mjs");
4296
4482
  const manager = getServiceManager();
4297
4483
  if (!await manager.isInstalled()) {
4298
4484
  console.log(chalk.dim("Service is not installed."));
@@ -4314,7 +4500,7 @@ async function serviceLogsCommand(options) {
4314
4500
  */
4315
4501
  async function serviceRestartCommand() {
4316
4502
  try {
4317
- const { getServiceManager } = await import("./service-4H4YceKv.mjs");
4503
+ const { getServiceManager } = await import("./service-MMjLsA9C.mjs");
4318
4504
  const manager = getServiceManager();
4319
4505
  if (!await manager.isInstalled()) {
4320
4506
  console.log(chalk.dim("Service is not installed."));
@@ -2,21 +2,31 @@ import { E as WS_URL, T as GRAPHQL_API_URL, w as API_URL } from "./index.mjs";
2
2
  import fs from "fs/promises";
3
3
  import path from "path";
4
4
  import os from "os";
5
- import { exec, spawn } from "child_process";
5
+ import { exec, spawn, spawnSync } from "child_process";
6
6
  import { promisify } from "util";
7
7
 
8
8
  //#region src/lib/service/systemd.ts
9
9
  const execAsync$1 = promisify(exec);
10
+ const SERVICE_NAME = "jive-task-runner";
11
+ const SYSTEM_SERVICE_PATH = `/etc/systemd/system/${SERVICE_NAME}.service`;
12
+ const USER_SERVICE_DIR = path.join(os.homedir(), ".config", "systemd", "user");
13
+ const LEGACY_USER_SERVICE_PATH = path.join(USER_SERVICE_DIR, `${SERVICE_NAME}.service`);
14
+ const ENV_DIR = "/etc/jive";
15
+ const ENV_FILE_PATH = `${ENV_DIR}/runner.env`;
10
16
  const SERVICE_TEMPLATE = `[Unit]
11
17
  Description=Jive Task Runner
12
18
  Documentation=https://getjive.app/docs
13
- After=network-online.target
19
+ After=network-online.target docker.service
14
20
  Wants=network-online.target
21
+ Requires=docker.service
15
22
 
16
23
  [Service]
17
24
  Type=simple
25
+ User={{SERVICE_USER}}
26
+ Group={{SERVICE_GROUP}}
27
+ WorkingDirectory={{WORKING_DIRECTORY}}
18
28
  ExecStart={{JIVE_BINARY_PATH}} task-runner start
19
- Restart=on-failure
29
+ Restart=always
20
30
  RestartSec=30
21
31
  TimeoutStartSec=90
22
32
  TimeoutStopSec=30
@@ -24,13 +34,10 @@ StartLimitBurst=5
24
34
  StartLimitIntervalSec=10m
25
35
  KillMode=mixed
26
36
  Environment="PATH={{NODE_BIN_PATH}}:/usr/local/bin:/usr/bin:/bin"
27
- Environment="JIVE_API_KEY={{JIVE_API_KEY}}"
28
- Environment="ANTHROPIC_API_KEY={{ANTHROPIC_API_KEY}}"
29
- Environment="JIVE_TEAM_ID={{JIVE_TEAM_ID}}"
30
- Environment="JIVE_RUNNER_ID={{JIVE_RUNNER_ID}}"
31
37
  Environment="JIVE_API_URL={{JIVE_API_URL}}"
32
38
  Environment="JIVE_WS_URL={{JIVE_WS_URL}}"
33
39
  Environment="JIVE_GRAPHQL_API_URL={{JIVE_GRAPHQL_API_URL}}"
40
+ EnvironmentFile={{ENV_FILE_PATH}}
34
41
 
35
42
  StandardOutput=journal
36
43
  StandardError=journal
@@ -43,16 +50,64 @@ ProtectKernelTunables=true
43
50
  RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
44
51
 
45
52
  [Install]
46
- WantedBy=default.target
53
+ WantedBy=multi-user.target
47
54
  `;
55
+ /**
56
+ * Check if the current process is running as root (UID 0)
57
+ */
58
+ function isRunningAsRoot() {
59
+ return process.getuid?.() === 0;
60
+ }
61
+ /**
62
+ * Re-execute the current command with sudo, preserving PATH and environment.
63
+ * This allows users to just run `jive task-runner install-service` without
64
+ * needing to manually invoke sudo with the correct environment.
65
+ */
66
+ function reExecWithSudo() {
67
+ const nodePath = process.execPath;
68
+ const scriptPath = process.argv[1];
69
+ const args = process.argv.slice(2);
70
+ console.log("Root privileges required. Re-running with sudo...\n");
71
+ const result = spawnSync("sudo", [
72
+ "--preserve-env=PATH,HOME",
73
+ `SUDO_USER=${process.env.USER}`,
74
+ `SUDO_UID=${process.getuid?.() || 1e3}`,
75
+ `SUDO_GID=${process.getgid?.() || 1e3}`,
76
+ nodePath,
77
+ scriptPath,
78
+ ...args
79
+ ], {
80
+ stdio: "inherit",
81
+ env: process.env
82
+ });
83
+ process.exit(result.status ?? 1);
84
+ }
85
+ /**
86
+ * Get the original user info when running with sudo
87
+ * Returns the user who invoked sudo, not root
88
+ */
89
+ function getCurrentUser() {
90
+ return {
91
+ uid: parseInt(process.env.SUDO_UID || String(process.getuid?.() || 1e3), 10),
92
+ gid: parseInt(process.env.SUDO_GID || String(process.getgid?.() || 1e3), 10),
93
+ username: process.env.SUDO_USER || process.env.USER || "jive",
94
+ homeDir: process.env.SUDO_USER ? `/home/${process.env.SUDO_USER}` : os.homedir()
95
+ };
96
+ }
48
97
  var SystemdServiceManager = class {
49
- servicePath;
50
- serviceName = "jive-task-runner";
51
- constructor() {
52
- const homeDir = os.homedir();
53
- this.servicePath = path.join(homeDir, ".config", "systemd", "user", `${this.serviceName}.service`);
98
+ servicePath = SYSTEM_SERVICE_PATH;
99
+ serviceName = SERVICE_NAME;
100
+ /**
101
+ * Check for legacy user service and handle migration.
102
+ * Call this BEFORE starting any spinners since it may prompt the user.
103
+ */
104
+ async checkAndMigrateLegacyService() {
105
+ if (!isRunningAsRoot()) reExecWithSudo();
106
+ await this.migrateFromUserService();
54
107
  }
55
108
  async install() {
109
+ if (!isRunningAsRoot()) reExecWithSudo();
110
+ const user = getCurrentUser();
56
111
  const { getRunnerConfig } = await import("./tasks-Py86q1u7.mjs");
57
112
  const { getCredentials } = await import("./config-7rVDmj2u.mjs");
58
113
  const runnerConfig = await getRunnerConfig();
@@ -72,41 +127,86 @@ var SystemdServiceManager = class {
72
127
  if (resolvedPath.includes(pattern)) throw new Error(`Detected unstable path for jive binary: ${resolvedPath}\nThis path contains '${pattern}' which may not persist across reboots.\n\nIf you're using fnm, try:\n 1. Run: fnm exec --using=default -- npm install -g @jive-ai/cli\n 2. Then run install-service again from a fresh terminal`);
73
128
  if (resolvedNodePath.includes(pattern)) throw new Error(`Detected unstable path for node binary: ${resolvedNodePath}\nThis path contains '${pattern}' which may not persist across reboots.\n\nIf you're using fnm, ensure you have a default node version set:\n fnm default <version>`);
74
129
  }
75
- const variables = {
76
- JIVE_BINARY_PATH: resolvedPath,
77
- NODE_BIN_PATH: nodeBinDir,
130
+ await this.createEnvironmentFile({
78
131
  JIVE_API_KEY: credentials.token,
79
132
  ANTHROPIC_API_KEY: credentials.anthropicApiKey || "",
80
133
  JIVE_TEAM_ID: runnerConfig.teamId,
81
- JIVE_RUNNER_ID: runnerConfig.id.toString(),
134
+ JIVE_RUNNER_ID: runnerConfig.id.toString()
135
+ });
136
+ const variables = {
137
+ SERVICE_USER: user.username,
138
+ SERVICE_GROUP: user.username,
139
+ WORKING_DIRECTORY: user.homeDir,
140
+ JIVE_BINARY_PATH: resolvedPath,
141
+ NODE_BIN_PATH: nodeBinDir,
82
142
  JIVE_API_URL: process.env.JIVE_API_URL || API_URL,
83
143
  JIVE_WS_URL: process.env.JIVE_WS_URL || WS_URL,
84
- JIVE_GRAPHQL_API_URL: process.env.JIVE_GRAPHQL_API_URL || GRAPHQL_API_URL
144
+ JIVE_GRAPHQL_API_URL: process.env.JIVE_GRAPHQL_API_URL || GRAPHQL_API_URL,
145
+ ENV_FILE_PATH
85
146
  };
86
147
  let serviceContent = SERVICE_TEMPLATE;
87
148
  for (const [key, value] of Object.entries(variables)) serviceContent = serviceContent.replace(new RegExp(`{{${key}}}`, "g"), value);
88
- const serviceDir = path.dirname(this.servicePath);
89
- await fs.mkdir(serviceDir, {
90
- recursive: true,
91
- mode: 493
92
- });
93
- const dirMode = (await fs.stat(serviceDir)).mode & 511;
94
- if (dirMode > 493) throw new Error(`Systemd user directory has overly permissive permissions: ${dirMode.toString(8)}\nExpected 0755 or stricter. Fix with: chmod 755 ${serviceDir}`);
95
- await fs.writeFile(this.servicePath, serviceContent, { mode: 384 });
149
+ await fs.writeFile(this.servicePath, serviceContent, { mode: 420 });
96
150
  try {
97
- await execAsync$1("systemctl --user daemon-reload", { timeout: 3e4 });
98
- await execAsync$1(`systemctl --user enable ${this.serviceName}`, { timeout: 3e4 });
151
+ await execAsync$1("systemctl daemon-reload", { timeout: 3e4 });
152
+ await execAsync$1(`systemctl enable ${this.serviceName}`, { timeout: 3e4 });
99
153
  } catch (error) {
100
154
  try {
101
155
  await fs.unlink(this.servicePath);
102
- await execAsync$1("systemctl --user daemon-reload", { timeout: 3e4 });
156
+ await fs.unlink(ENV_FILE_PATH);
157
+ await execAsync$1("systemctl daemon-reload", { timeout: 3e4 });
103
158
  } catch (cleanupError) {
104
159
  console.error("Failed to clean up after installation failure:", cleanupError);
105
160
  }
106
161
  throw new Error(`Service installation failed: ${error.message}\nPartial installation has been rolled back.`);
107
162
  }
108
163
  }
164
+ /**
165
+ * Create the environment file with sensitive credentials
166
+ */
167
+ async createEnvironmentFile(vars) {
168
+ await fs.mkdir(ENV_DIR, {
169
+ recursive: true,
170
+ mode: 493
171
+ });
172
+ const content = Object.entries(vars).map(([key, value]) => `${key}=${value}`).join("\n") + "\n";
173
+ await fs.writeFile(ENV_FILE_PATH, content, { mode: 384 });
174
+ }
175
+ /**
176
+ * Check for and migrate from legacy user service
177
+ */
178
+ async migrateFromUserService() {
179
+ try {
180
+ await fs.access(LEGACY_USER_SERVICE_PATH);
181
+ } catch {
182
+ return;
183
+ }
184
+ const { default: prompts } = await import("prompts");
185
+ console.log("\n⚠ Found existing user service at:");
186
+ console.log(` ${LEGACY_USER_SERVICE_PATH}\n`);
187
+ const { migrate } = await prompts({
188
+ type: "confirm",
189
+ name: "migrate",
190
+ message: "Migrate to system service? (This will remove the old user service)",
191
+ initial: true
192
+ });
193
+ if (!migrate) throw new Error("Migration cancelled. Remove the user service first or choose to migrate.");
194
+ const user = getCurrentUser();
195
+ console.log("Stopping and removing legacy user service...");
196
+ try {
197
+ await execAsync$1(`sudo -u ${user.username} systemctl --user stop ${this.serviceName}`, { timeout: 3e4 });
198
+ } catch {}
199
+ try {
200
+ await execAsync$1(`sudo -u ${user.username} systemctl --user disable ${this.serviceName}`, { timeout: 3e4 });
201
+ } catch {}
202
+ await fs.unlink(LEGACY_USER_SERVICE_PATH);
203
+ try {
204
+ await execAsync$1(`sudo -u ${user.username} systemctl --user daemon-reload`, { timeout: 3e4 });
205
+ } catch {}
206
+ console.log("Legacy user service removed successfully.\n");
207
+ }
109
208
  async uninstall() {
209
+ if (!isRunningAsRoot()) reExecWithSudo();
110
210
  try {
111
211
  await this.stop();
112
212
  } catch (error) {
@@ -114,7 +214,7 @@ var SystemdServiceManager = class {
114
214
  else console.warn(`Warning: Failed to stop service: ${error.message}`);
115
215
  }
116
216
  try {
117
- await execAsync$1(`systemctl --user disable ${this.serviceName}`, { timeout: 3e4 });
217
+ await execAsync$1(`systemctl disable ${this.serviceName}`, { timeout: 3e4 });
118
218
  } catch (error) {
119
219
  if (error.stderr?.includes("No such file") || error.message?.includes("not be found")) {} else if (error.code === "EACCES") console.warn("Warning: Permission denied when disabling service");
120
220
  else console.warn(`Warning: Failed to disable service: ${error.message}`);
@@ -124,16 +224,24 @@ var SystemdServiceManager = class {
124
224
  } catch (error) {
125
225
  if (error.code !== "ENOENT") throw new Error(`Failed to remove service file: ${error.message}`);
126
226
  }
127
- await execAsync$1("systemctl --user daemon-reload", { timeout: 3e4 });
227
+ try {
228
+ await fs.unlink(ENV_FILE_PATH);
229
+ } catch (error) {
230
+ if (error.code !== "ENOENT") console.warn(`Warning: Failed to remove environment file: ${error.message}`);
231
+ }
232
+ try {
233
+ await fs.rmdir(ENV_DIR);
234
+ } catch {}
235
+ await execAsync$1("systemctl daemon-reload", { timeout: 3e4 });
128
236
  }
129
237
  async start() {
130
- await execAsync$1(`systemctl --user start ${this.serviceName}`, { timeout: 3e4 });
238
+ await execAsync$1(`systemctl start ${this.serviceName}`, { timeout: 3e4 });
131
239
  }
132
240
  async stop() {
133
- await execAsync$1(`systemctl --user stop ${this.serviceName}`, { timeout: 3e4 });
241
+ await execAsync$1(`systemctl stop ${this.serviceName}`, { timeout: 3e4 });
134
242
  }
135
243
  async restart() {
136
- await execAsync$1(`systemctl --user restart ${this.serviceName}`, { timeout: 3e4 });
244
+ await execAsync$1(`systemctl restart ${this.serviceName}`, { timeout: 3e4 });
137
245
  }
138
246
  async status() {
139
247
  if (!await this.isInstalled()) return {
@@ -142,11 +250,11 @@ var SystemdServiceManager = class {
142
250
  enabled: false
143
251
  };
144
252
  try {
145
- const { stdout } = await execAsync$1(`systemctl --user status ${this.serviceName} --no-pager`, { timeout: 3e4 });
253
+ const { stdout } = await execAsync$1(`systemctl status ${this.serviceName} --no-pager`, { timeout: 3e4 });
146
254
  const running = stdout.includes("Active: active (running)");
147
255
  const pid = this.extractPid(stdout);
148
256
  const uptime = this.extractUptime(stdout);
149
- const { stdout: isEnabledOutput } = await execAsync$1(`systemctl --user is-enabled ${this.serviceName}`, { timeout: 3e4 });
257
+ const { stdout: isEnabledOutput } = await execAsync$1(`systemctl is-enabled ${this.serviceName}`, { timeout: 3e4 });
150
258
  return {
151
259
  installed: true,
152
260
  running,
@@ -155,7 +263,7 @@ var SystemdServiceManager = class {
155
263
  pid
156
264
  };
157
265
  } catch (error) {
158
- const { stdout: isEnabledOutput } = await execAsync$1(`systemctl --user is-enabled ${this.serviceName}`, { timeout: 3e4 }).catch(() => ({ stdout: "disabled" }));
266
+ const { stdout: isEnabledOutput } = await execAsync$1(`systemctl is-enabled ${this.serviceName}`, { timeout: 3e4 }).catch(() => ({ stdout: "disabled" }));
159
267
  return {
160
268
  installed: true,
161
269
  running: false,
@@ -164,11 +272,7 @@ var SystemdServiceManager = class {
164
272
  }
165
273
  }
166
274
  async logs(options) {
167
- const args = [
168
- "--user",
169
- "-u",
170
- this.serviceName
171
- ];
275
+ const args = ["-u", this.serviceName];
172
276
  if (options?.follow) args.push("-f");
173
277
  if (options?.lines) args.push("-n", options.lines.toString());
174
278
  const logsProcess = spawn("journalctl", args, { stdio: "inherit" });
@@ -240,6 +344,18 @@ async function validateServiceInstallation() {
240
344
  const checks = [];
241
345
  let canInstall = true;
242
346
  let hasWarnings = false;
347
+ if (!isRunningAsRoot()) {
348
+ checks.push({
349
+ name: "Root privileges",
350
+ status: "warning",
351
+ message: "Will prompt for sudo password"
352
+ });
353
+ hasWarnings = true;
354
+ } else checks.push({
355
+ name: "Root privileges",
356
+ status: "success",
357
+ message: "Running as root"
358
+ });
243
359
  try {
244
360
  const { getRunnerConfig } = await import("./tasks-Py86q1u7.mjs");
245
361
  const runnerConfig = await getRunnerConfig();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "private": false,
3
3
  "name": "@jive-ai/cli",
4
- "version": "0.0.45",
4
+ "version": "0.0.47",
5
5
  "main": "index.js",
6
6
  "files": [
7
7
  "dist",
@@ -15,7 +15,8 @@
15
15
  "test": "echo \"Error: no test specified\" && exit 1",
16
16
  "typecheck": "tsc --noEmit",
17
17
  "build": "tsdown && npm pack && npm install -g jive-ai-cli-*.tgz",
18
- "docker:build": "bun run build && .docker/build.sh",
18
+ "docker:clean": "docker rmi jiveai/task:latest jiveai/task:$npm_package_version",
19
+ "docker:build": "bun run build && npm run docker:clean && .docker/build.sh",
19
20
  "docker:push": ".docker/build.sh --push",
20
21
  "prepublishOnly": "npm run typecheck && npm run build"
21
22
  },