@voidlabs.gg/voidshift-agents 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/dist/control.d.ts +1 -1
- package/dist/control.js +23 -26
- package/dist/index.js +21 -21
- package/dist/launchd.d.ts +1 -0
- package/dist/launchd.js +6 -0
- package/dist/secrets.d.ts +9 -0
- package/dist/secrets.js +55 -0
- package/dist/service.d.ts +9 -0
- package/dist/service.js +18 -0
- package/dist/systemd.d.ts +14 -0
- package/dist/systemd.js +85 -0
- package/package.json +3 -5
- package/dist/keychain.d.ts +0 -3
- package/dist/keychain.js +0 -19
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ One CLI for the Void dev tools. Install it once, run `vsf` (short for
|
|
|
5
5
|
defaults you can change. Everything setup does can be seen and changed later
|
|
6
6
|
from the command line.
|
|
7
7
|
|
|
8
|
-
> Early release. macOS
|
|
8
|
+
> Early release. macOS and Linux (awake is macOS only); Windows is next.
|
|
9
9
|
|
|
10
10
|
```sh
|
|
11
11
|
npm i -g @voidlabs.gg/voidshift
|
|
@@ -104,10 +104,13 @@ to trust the recall hook once: run `/hooks` in Codex.
|
|
|
104
104
|
- **Your own logins.** Agents run the official `claude` and `codex` CLIs you
|
|
105
105
|
already use, signed in with your own account. VoidShift never reads or
|
|
106
106
|
stores those credentials. The Linear API key for the runner lives in your
|
|
107
|
-
macOS Keychain.
|
|
107
|
+
macOS Keychain, or on Linux in `~/.config/voidshift/agents/secrets.json`
|
|
108
|
+
(mode 0600).
|
|
108
109
|
- **Trust.** The Dev agent can run shell commands inside its copy of the repo
|
|
109
110
|
so it can install and test. Review its pull requests like any other.
|
|
110
111
|
- **Your config** lives in `~/.config/voidshift`.
|
|
112
|
+
- **Linux.** The runner is a systemd user timer. Run
|
|
113
|
+
`sudo loginctl enable-linger $USER` so it keeps going after you log out.
|
|
111
114
|
|
|
112
115
|
```sh
|
|
113
116
|
vsf doctor # check everything is set up, and whether an update is out
|
package/dist/control.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Current, type RunEntry } from './state.ts';
|
|
2
2
|
import type { Check, ToolCommand, ToolContext } from './types.ts';
|
|
3
|
+
export { parsePlistArgs } from './launchd.ts';
|
|
3
4
|
export declare function parseAge(input: string): number | null;
|
|
4
5
|
export declare function formatDuration(ms: number): string;
|
|
5
6
|
export declare function stampDate(stamp: string): Date;
|
|
@@ -12,7 +13,6 @@ export declare function scanRunnerErrors(text: string, now: number): {
|
|
|
12
13
|
count: number;
|
|
13
14
|
last: string;
|
|
14
15
|
};
|
|
15
|
-
export declare function parsePlistArgs(plist: string): string[];
|
|
16
16
|
export declare const controlUsage: Record<string, string>;
|
|
17
17
|
export interface CancelDeps {
|
|
18
18
|
ours: (pid: number) => boolean;
|
package/dist/control.js
CHANGED
|
@@ -3,9 +3,9 @@ import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, r
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { parseArgs } from 'node:util';
|
|
5
5
|
import { agentsDir, readAgentsConfig } from "./config.js";
|
|
6
|
-
import { readLinearKey } from "./
|
|
6
|
+
import { readLinearKey } from "./secrets.js";
|
|
7
7
|
import { isPaused, listRuns, ours, clearCurrent, liveCurrent, readCurrent, setPaused, writeRun } from "./state.js";
|
|
8
|
-
import {
|
|
8
|
+
import { runnerService } from "./service.js";
|
|
9
9
|
import { createLinear } from "./linear.js";
|
|
10
10
|
import { parseStep, resolveName, stepNames } from "./names.js";
|
|
11
11
|
import { isStatusLabel } from "./workflow.js";
|
|
@@ -14,8 +14,9 @@ const HOUR = 3_600_000;
|
|
|
14
14
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
15
15
|
const CARD_ID = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
|
|
16
16
|
const ISO_LINE = /^(\d{4}-\d\d-\d\dT[\d:.]+Z)\s*(.*)$/;
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
export { parsePlistArgs } from "./launchd.js";
|
|
18
|
+
function linear(configDir) {
|
|
19
|
+
const key = readLinearKey(configDir);
|
|
19
20
|
if (!key)
|
|
20
21
|
throw new Error('No Linear API key. Run vsf agents key');
|
|
21
22
|
return createLinear(key);
|
|
@@ -88,12 +89,6 @@ export function scanRunnerErrors(text, now) {
|
|
|
88
89
|
}
|
|
89
90
|
return { count: bad.length, last: bad.at(-1) ?? '' };
|
|
90
91
|
}
|
|
91
|
-
// The <string> values of a launchd plist's ProgramArguments.
|
|
92
|
-
export function parsePlistArgs(plist) {
|
|
93
|
-
const block = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(plist)?.[1] ?? '';
|
|
94
|
-
const unxml = (s) => s.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
|
95
|
-
return [...block.matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => unxml(m[1]));
|
|
96
|
-
}
|
|
97
92
|
function lastTickOf(text) {
|
|
98
93
|
for (const line of text.split('\n').reverse()) {
|
|
99
94
|
const m = ISO_LINE.exec(line);
|
|
@@ -160,7 +155,7 @@ const guard = (fn) => async (args, ctx) => {
|
|
|
160
155
|
};
|
|
161
156
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
162
157
|
function runnerState(ctx) {
|
|
163
|
-
if (!
|
|
158
|
+
if (!runnerService().loaded())
|
|
164
159
|
return 'stopped';
|
|
165
160
|
return isPaused(ctx.configDir) ? 'paused' : 'running';
|
|
166
161
|
}
|
|
@@ -219,7 +214,7 @@ async function startCmd(ctx) {
|
|
|
219
214
|
return 1;
|
|
220
215
|
}
|
|
221
216
|
try {
|
|
222
|
-
await linear().viewer();
|
|
217
|
+
await linear(ctx.configDir).viewer();
|
|
223
218
|
}
|
|
224
219
|
catch (err) {
|
|
225
220
|
const msg = err.message;
|
|
@@ -227,18 +222,18 @@ async function startCmd(ctx) {
|
|
|
227
222
|
return 1;
|
|
228
223
|
}
|
|
229
224
|
setPaused(ctx.configDir, false);
|
|
230
|
-
if (!
|
|
225
|
+
if (!runnerService().start(ctx.home, program(ctx), logPath(ctx.configDir))) {
|
|
231
226
|
ctx.log('Could not start the runner. Check vsf agents logs.');
|
|
232
227
|
return 1;
|
|
233
228
|
}
|
|
234
229
|
return 0;
|
|
235
230
|
}
|
|
236
231
|
async function stopCmd(ctx) {
|
|
237
|
-
|
|
238
|
-
//
|
|
232
|
+
runnerService().stop(ctx.home);
|
|
233
|
+
// Stopping the runner may have stopped the pass; finish or tidy up whatever the lock still names.
|
|
239
234
|
const c = readCurrent(ctx.configDir);
|
|
240
235
|
if (c)
|
|
241
|
-
await cancelCurrent(ctx, c, realDeps(ctx, linear));
|
|
236
|
+
await cancelCurrent(ctx, c, realDeps(ctx, () => linear(ctx.configDir)));
|
|
242
237
|
ctx.log('Runner stopped.');
|
|
243
238
|
return 0;
|
|
244
239
|
}
|
|
@@ -358,7 +353,7 @@ const run = guard(async (args, ctx) => {
|
|
|
358
353
|
return 2;
|
|
359
354
|
}
|
|
360
355
|
}
|
|
361
|
-
const code = await tick({ configDir: ctx.configDir, config, linear: linear(), log: ctx.log, selfCommand: ctx.selfCommand }, card, { workflow, from });
|
|
356
|
+
const code = await tick({ configDir: ctx.configDir, config, linear: linear(ctx.configDir), log: ctx.log, selfCommand: ctx.selfCommand }, card, { workflow, from });
|
|
362
357
|
if (!card && code === 0) {
|
|
363
358
|
if (isPaused(ctx.configDir))
|
|
364
359
|
ctx.log('The runner is paused, so nothing was picked up. vsf agents resume, or name a card.');
|
|
@@ -383,7 +378,7 @@ const cancel = guard(async (args, ctx) => {
|
|
|
383
378
|
ctx.log(`${c.card ?? 'Another card'} is running, not ${card}.`);
|
|
384
379
|
return 1;
|
|
385
380
|
}
|
|
386
|
-
await cancelCurrent(ctx, c, realDeps(ctx, linear));
|
|
381
|
+
await cancelCurrent(ctx, c, realDeps(ctx, () => linear(ctx.configDir)));
|
|
387
382
|
return 0;
|
|
388
383
|
});
|
|
389
384
|
const retry = guard(async (args, ctx) => {
|
|
@@ -399,7 +394,7 @@ const retry = guard(async (args, ctx) => {
|
|
|
399
394
|
}
|
|
400
395
|
const config = readAgentsConfig(ctx.configDir);
|
|
401
396
|
const names = Object.keys(config.workflows);
|
|
402
|
-
const l = linear();
|
|
397
|
+
const l = linear(ctx.configDir);
|
|
403
398
|
const card = await l.card(id);
|
|
404
399
|
const startsOn = (n) => config.workflows[n].triggers.some((t) => t.team === card.teamKey);
|
|
405
400
|
let chosen;
|
|
@@ -479,7 +474,7 @@ const show = guard(async (args, ctx) => {
|
|
|
479
474
|
}
|
|
480
475
|
let labels = null;
|
|
481
476
|
try {
|
|
482
|
-
labels = (await linear().card(id)).labels;
|
|
477
|
+
labels = (await linear(ctx.configDir).card(id)).labels;
|
|
483
478
|
}
|
|
484
479
|
catch {
|
|
485
480
|
// live labels are optional
|
|
@@ -646,16 +641,16 @@ const clean = guard(async (args, ctx) => {
|
|
|
646
641
|
ctx.log(`Removed ${plural(worktrees.length, 'worktree')} and ${plural(old.length, 'run')}.`);
|
|
647
642
|
return 0;
|
|
648
643
|
});
|
|
649
|
-
// Internal: launchd runs this every minute.
|
|
644
|
+
// Internal: the runner (launchd or systemd) runs this every minute.
|
|
650
645
|
const tickCmd = async (_args, ctx) => {
|
|
651
646
|
const stamped = (l) => ctx.log(`${new Date().toISOString()} ${l}`);
|
|
652
647
|
try {
|
|
653
648
|
const config = readAgentsConfig(ctx.configDir);
|
|
654
|
-
if (!readLinearKey()) {
|
|
649
|
+
if (!readLinearKey(ctx.configDir)) {
|
|
655
650
|
stamped('No Linear API key. Run vsf agents key.');
|
|
656
651
|
return 1;
|
|
657
652
|
}
|
|
658
|
-
return await tick({ configDir: ctx.configDir, config, linear: linear(), log: stamped, selfCommand: ctx.selfCommand });
|
|
653
|
+
return await tick({ configDir: ctx.configDir, config, linear: linear(ctx.configDir), log: stamped, selfCommand: ctx.selfCommand });
|
|
659
654
|
}
|
|
660
655
|
catch (err) {
|
|
661
656
|
stamped(`Could not run the check: ${err.message}`);
|
|
@@ -690,9 +685,10 @@ export async function controlChecks(ctx) {
|
|
|
690
685
|
: state === 'paused'
|
|
691
686
|
? { name: 'agent runner', status: 'warn', detail: 'paused', fix: 'vsf agents resume' }
|
|
692
687
|
: { name: 'agent runner', status: 'warn', detail: 'not running', fix: 'vsf agents start' });
|
|
693
|
-
const
|
|
694
|
-
|
|
695
|
-
|
|
688
|
+
const service = runnerService();
|
|
689
|
+
const installed = service.programArgs(ctx.home);
|
|
690
|
+
if (installed) {
|
|
691
|
+
const missing = installed.filter((a) => a.startsWith('/') && !existsSync(a));
|
|
696
692
|
checks.push(missing.length
|
|
697
693
|
? { name: 'runner program', status: 'fail', detail: `runner points at a missing ${missing[0]}`, fix: 'vsf agents restart' }
|
|
698
694
|
: { name: 'runner program', status: 'ok', detail: 'found' });
|
|
@@ -706,6 +702,7 @@ export async function controlChecks(ctx) {
|
|
|
706
702
|
? { name: 'runner errors', status: 'warn', detail: `${plural(count, 'error')} in the last hour, last: ${last.slice(0, 120)}`, fix: 'vsf agents logs' }
|
|
707
703
|
: { name: 'runner errors', status: 'ok', detail: 'no errors in the last hour' });
|
|
708
704
|
}
|
|
705
|
+
checks.push(...service.checks());
|
|
709
706
|
checks.push(engineLogin(readAgentsConfig(ctx.configDir).engine));
|
|
710
707
|
return checks;
|
|
711
708
|
}
|
package/dist/index.js
CHANGED
|
@@ -6,8 +6,8 @@ import { parseArgs } from 'node:util';
|
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { AGENTS, STEPS, TEMPLATES, isProducer, agentsDir, describeSteps, describeTriggers, describeWorkflow, stepEnabled, visibleAgents, readAgentsConfig, writeAgentsConfig, DEFAULTS } from "./config.js";
|
|
8
8
|
import { controlChecks, controlCommands, controlUsage } from "./control.js";
|
|
9
|
-
import { looksLikeLinearKey, readLinearKey, storeLinearKey } from "./
|
|
10
|
-
import {
|
|
9
|
+
import { keyStore, looksLikeLinearKey, readLinearKey, storeLinearKey } from "./secrets.js";
|
|
10
|
+
import { runnerService } from "./service.js";
|
|
11
11
|
import { createLinear } from "./linear.js";
|
|
12
12
|
import { duplicateTriggerWarnings, moveKey, parseTrigger, renameKey, sameTrigger, validateConfig, validateWorkflow, workflowsUsing } from "./manage.js";
|
|
13
13
|
import { parseStep, resolveName, stepNames } from "./names.js";
|
|
@@ -81,14 +81,14 @@ async function chooseHomes(ui, configDir, saved) {
|
|
|
81
81
|
return homes;
|
|
82
82
|
}
|
|
83
83
|
// Checks the key against Linear, and only then stores it.
|
|
84
|
-
async function verifyAndStore(key, info, warn) {
|
|
84
|
+
async function verifyAndStore(configDir, key, info, warn) {
|
|
85
85
|
try {
|
|
86
86
|
const me = await createLinear(key).viewer();
|
|
87
|
-
if (!storeLinearKey(key)) {
|
|
88
|
-
warn(
|
|
87
|
+
if (!storeLinearKey(configDir, key)) {
|
|
88
|
+
warn(`Linear accepted the key but it could not be stored in ${keyStore()}.`);
|
|
89
89
|
return null;
|
|
90
90
|
}
|
|
91
|
-
info(`Linear connected as ${me.name}. Key stored in
|
|
91
|
+
info(`Linear connected as ${me.name}. Key stored in ${keyStore()}.`);
|
|
92
92
|
return me.name;
|
|
93
93
|
}
|
|
94
94
|
catch (err) {
|
|
@@ -97,14 +97,14 @@ async function verifyAndStore(key, info, warn) {
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
// Asks for the key, checks it against Linear, and only then stores it.
|
|
100
|
-
async function setupKey(ui) {
|
|
100
|
+
async function setupKey(ui, configDir) {
|
|
101
101
|
ui.info('Agents need a Linear personal API key (Linear > Settings > Security & access > API keys).');
|
|
102
102
|
const key = await ui.secret('Paste your Linear API key', (v) => (looksLikeLinearKey(v.trim()) ? undefined : 'Linear keys start with lin_api_'));
|
|
103
|
-
return verifyAndStore(key.trim(), ui.info, ui.warn);
|
|
103
|
+
return verifyAndStore(configDir, key.trim(), ui.info, ui.warn);
|
|
104
104
|
}
|
|
105
|
-
async function currentLinearUser() {
|
|
105
|
+
async function currentLinearUser(configDir) {
|
|
106
106
|
try {
|
|
107
|
-
return readLinearKey() ? (await linear().viewer()).name : null;
|
|
107
|
+
return readLinearKey(configDir) ? (await linear(configDir).viewer()).name : null;
|
|
108
108
|
}
|
|
109
109
|
catch {
|
|
110
110
|
return null;
|
|
@@ -181,8 +181,8 @@ function syncLinks(chosen, all, enabled, log) {
|
|
|
181
181
|
log(`Agents linked in ${homeLabel(home)}: ${linked} linked${skipped ? `, ${skipped} skipped (something else is already there)` : ''}`);
|
|
182
182
|
}
|
|
183
183
|
}
|
|
184
|
-
function linear() {
|
|
185
|
-
const key = readLinearKey();
|
|
184
|
+
function linear(configDir) {
|
|
185
|
+
const key = readLinearKey(configDir);
|
|
186
186
|
if (!key)
|
|
187
187
|
throw new Error('No Linear API key. Run vsf agents key');
|
|
188
188
|
return createLinear(key);
|
|
@@ -489,7 +489,7 @@ function pickWorkflow(config, input, ctx) {
|
|
|
489
489
|
}
|
|
490
490
|
async function teamsOrNull(ctx) {
|
|
491
491
|
try {
|
|
492
|
-
return await linear().teams();
|
|
492
|
+
return await linear(ctx.configDir).teams();
|
|
493
493
|
}
|
|
494
494
|
catch (err) {
|
|
495
495
|
ctx.log(`Could not list Linear teams: ${err.message}`);
|
|
@@ -1045,22 +1045,22 @@ const agents = {
|
|
|
1045
1045
|
config.models = { ...DEFAULTS.models };
|
|
1046
1046
|
}
|
|
1047
1047
|
config.engine = engine;
|
|
1048
|
-
const name = await currentLinearUser();
|
|
1048
|
+
const name = await currentLinearUser(ctx.configDir);
|
|
1049
1049
|
if (!name || !(await ui.confirm(`Keep using Linear as ${name}?`))) {
|
|
1050
|
-
if (!(await setupKey(ui)))
|
|
1050
|
+
if (!(await setupKey(ui, ctx.configDir)))
|
|
1051
1051
|
throw new Error('Linear is not connected. Run vsf setup agents to retry.');
|
|
1052
1052
|
}
|
|
1053
1053
|
await setupProjects(ui, config, ctx.log);
|
|
1054
|
-
await setupWorkflows(ui, config, await linear().teams(), ctx.log);
|
|
1054
|
+
await setupWorkflows(ui, config, await linear(ctx.configDir).teams(), ctx.log);
|
|
1055
1055
|
writeAgentsConfig(ctx.configDir, config);
|
|
1056
1056
|
if (Object.keys(config.workflows).length && (await ui.confirm('Start the background runner now? It checks Linear every minute for cards that start your workflows.'))) {
|
|
1057
|
-
const ok =
|
|
1057
|
+
const ok = runnerService().start(ctx.home, [...ctx.selfCommand, 'agents', 'tick'], join(agentsDir(ctx.configDir), 'runner.log'));
|
|
1058
1058
|
ctx.log(ok ? 'Runner started.' : 'Could not start the runner. Try vsf agents start.');
|
|
1059
1059
|
}
|
|
1060
1060
|
ctx.log('In Claude Code or Codex, connect Linear too, then use /vs-plan, /vs-plan-review and /vs-publish on a card.');
|
|
1061
1061
|
},
|
|
1062
1062
|
async uninstall(ctx) {
|
|
1063
|
-
|
|
1063
|
+
runnerService().stop(ctx.home);
|
|
1064
1064
|
syncLinks([], candidateHomes(ctx.configDir, readAgentsConfig(ctx.configDir).homes), [], ctx.log);
|
|
1065
1065
|
ctx.log('Agents unlinked and the runner stopped. Config and the Linear key were kept.');
|
|
1066
1066
|
},
|
|
@@ -1078,7 +1078,7 @@ const agents = {
|
|
|
1078
1078
|
: { name: `agents in ${homeLabel(h)}`, status: 'warn', detail: `missing ${missing.join(', ')}`, fix: `vsf agents link ${tilde(h.dir)}` });
|
|
1079
1079
|
}
|
|
1080
1080
|
try {
|
|
1081
|
-
const me = await linear().viewer();
|
|
1081
|
+
const me = await linear(ctx.configDir).viewer();
|
|
1082
1082
|
checks.push({ name: 'Linear', status: 'ok', detail: `connected as ${me.name}` });
|
|
1083
1083
|
}
|
|
1084
1084
|
catch (err) {
|
|
@@ -1142,13 +1142,13 @@ const agents = {
|
|
|
1142
1142
|
return 2;
|
|
1143
1143
|
}
|
|
1144
1144
|
if (interactive())
|
|
1145
|
-
return (await setupKey(ctx.ui)) ? 0 : 1;
|
|
1145
|
+
return (await setupKey(ctx.ui, ctx.configDir)) ? 0 : 1;
|
|
1146
1146
|
const key = readFileSync(0, 'utf8').trim();
|
|
1147
1147
|
if (!looksLikeLinearKey(key)) {
|
|
1148
1148
|
ctx.log('Linear keys start with lin_api_');
|
|
1149
1149
|
return 2;
|
|
1150
1150
|
}
|
|
1151
|
-
return (await verifyAndStore(key, ctx.log, ctx.log)) ? 0 : 1;
|
|
1151
|
+
return (await verifyAndStore(ctx.configDir, key, ctx.log, ctx.log)) ? 0 : 1;
|
|
1152
1152
|
},
|
|
1153
1153
|
async help(args, ctx) {
|
|
1154
1154
|
const want = args.join(' ').trim();
|
package/dist/launchd.d.ts
CHANGED
|
@@ -5,3 +5,4 @@ export declare function runnerEnv(): Record<string, string>;
|
|
|
5
5
|
export declare const runnerLoaded: () => boolean;
|
|
6
6
|
export declare function startRunner(home: string, program: string[], logPath: string): boolean;
|
|
7
7
|
export declare function stopRunner(home: string): void;
|
|
8
|
+
export declare function parsePlistArgs(plist: string): string[];
|
package/dist/launchd.js
CHANGED
|
@@ -66,3 +66,9 @@ export function stopRunner(home) {
|
|
|
66
66
|
launchctl(['bootout', `${domain()}/${LABEL}`]);
|
|
67
67
|
rmSync(plistPath(home), { force: true });
|
|
68
68
|
}
|
|
69
|
+
// The <string> values of a launchd plist's ProgramArguments.
|
|
70
|
+
export function parsePlistArgs(plist) {
|
|
71
|
+
const block = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(plist)?.[1] ?? '';
|
|
72
|
+
const unxml = (s) => s.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
|
73
|
+
return [...block.matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => unxml(m[1]));
|
|
74
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const looksLikeLinearKey: (key: string) => boolean;
|
|
2
|
+
export declare const secretsPath: (configDir: string) => string;
|
|
3
|
+
export declare const keyStore: (platform?: NodeJS.Platform) => "your macOS Keychain" | "a file only you can read";
|
|
4
|
+
export declare function readSecretsFile(path: string): Record<string, string>;
|
|
5
|
+
export declare function writeSecretsFile(path: string, values: Record<string, string>): void;
|
|
6
|
+
export declare function readSecret(configDir: string, name: string, platform?: NodeJS.Platform): string | null;
|
|
7
|
+
export declare function storeSecret(configDir: string, name: string, value: string, platform?: NodeJS.Platform): boolean;
|
|
8
|
+
export declare const readLinearKey: (configDir: string) => string | null;
|
|
9
|
+
export declare const storeLinearKey: (configDir: string, key: string) => boolean;
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { userInfo } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { agentsDir } from "./config.js";
|
|
6
|
+
// Keys live in the macOS Keychain, or on other platforms in a file only this
|
|
7
|
+
// user can read, in the agents config folder.
|
|
8
|
+
const KEY_SHAPE = /^lin_api_[A-Za-z0-9]+$/;
|
|
9
|
+
// Values the Keychain is handed on stdin; anything that could break out of
|
|
10
|
+
// the `security -i` command line is refused.
|
|
11
|
+
const SAFE_VALUE = /^[A-Za-z0-9_\-.:/+=]+$/;
|
|
12
|
+
export const looksLikeLinearKey = (key) => KEY_SHAPE.test(key);
|
|
13
|
+
export const secretsPath = (configDir) => join(agentsDir(configDir), 'secrets.json');
|
|
14
|
+
// Where keys are kept on this machine, for messages.
|
|
15
|
+
export const keyStore = (platform = process.platform) => platform === 'darwin' ? 'your macOS Keychain' : 'a file only you can read';
|
|
16
|
+
// The secrets file, refused when anyone but its owner can read it.
|
|
17
|
+
export function readSecretsFile(path) {
|
|
18
|
+
if (!existsSync(path))
|
|
19
|
+
return {};
|
|
20
|
+
const mode = statSync(path).mode & 0o777;
|
|
21
|
+
if (mode & 0o077)
|
|
22
|
+
throw new Error(`${path} can be read by other users (mode ${mode.toString(8)}). Run chmod 600 on it.`);
|
|
23
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
24
|
+
}
|
|
25
|
+
// Written to a temporary file with mode 0600, then renamed into place, so the
|
|
26
|
+
// file is never readable by others, not even for a moment.
|
|
27
|
+
export function writeSecretsFile(path, values) {
|
|
28
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
29
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
30
|
+
writeFileSync(tmp, `${JSON.stringify(values, null, 2)}\n`, { mode: 0o600 });
|
|
31
|
+
chmodSync(tmp, 0o600);
|
|
32
|
+
renameSync(tmp, path);
|
|
33
|
+
}
|
|
34
|
+
const service = (name) => `voidshift-${name}`;
|
|
35
|
+
export function readSecret(configDir, name, platform = process.platform) {
|
|
36
|
+
if (platform === 'darwin') {
|
|
37
|
+
const r = spawnSync('/usr/bin/security', ['find-generic-password', '-s', service(name), '-w'], { encoding: 'utf8' });
|
|
38
|
+
return (r.status === 0 ? r.stdout.trim() : '') || null;
|
|
39
|
+
}
|
|
40
|
+
return readSecretsFile(secretsPath(configDir))[name] || null;
|
|
41
|
+
}
|
|
42
|
+
export function storeSecret(configDir, name, value, platform = process.platform) {
|
|
43
|
+
if (!SAFE_VALUE.test(value) || !/^[a-z-]+$/.test(name))
|
|
44
|
+
return false;
|
|
45
|
+
if (platform === 'darwin') {
|
|
46
|
+
// On stdin through `security -i`, so the value never appears in a process list.
|
|
47
|
+
const command = `add-generic-password -U -s ${service(name)} -a ${userInfo().username} -w ${value}\n`;
|
|
48
|
+
return spawnSync('/usr/bin/security', ['-i'], { input: command, encoding: 'utf8' }).status === 0;
|
|
49
|
+
}
|
|
50
|
+
const path = secretsPath(configDir);
|
|
51
|
+
writeSecretsFile(path, { ...readSecretsFile(path), [name]: value });
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
export const readLinearKey = (configDir) => readSecret(configDir, 'linear');
|
|
55
|
+
export const storeLinearKey = (configDir, key) => looksLikeLinearKey(key) && storeSecret(configDir, 'linear', key);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Check } from './types.ts';
|
|
2
|
+
export interface RunnerService {
|
|
3
|
+
loaded(): boolean;
|
|
4
|
+
start(home: string, program: string[], logPath: string): boolean;
|
|
5
|
+
stop(home: string): void;
|
|
6
|
+
programArgs(home: string): string[] | null;
|
|
7
|
+
checks(): Check[];
|
|
8
|
+
}
|
|
9
|
+
export declare const runnerService: (platform?: NodeJS.Platform) => RunnerService;
|
package/dist/service.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import * as launchd from "./launchd.js";
|
|
3
|
+
import * as systemd from "./systemd.js";
|
|
4
|
+
const mac = {
|
|
5
|
+
loaded: launchd.runnerLoaded,
|
|
6
|
+
start: launchd.startRunner,
|
|
7
|
+
stop: launchd.stopRunner,
|
|
8
|
+
programArgs: (home) => (existsSync(launchd.plistPath(home)) ? launchd.parsePlistArgs(readFileSync(launchd.plistPath(home), 'utf8')) : null),
|
|
9
|
+
checks: () => [],
|
|
10
|
+
};
|
|
11
|
+
const linux = {
|
|
12
|
+
loaded: systemd.runnerLoaded,
|
|
13
|
+
start: (home, program, logPath) => systemd.startRunner(home, program, launchd.runnerEnv(), logPath),
|
|
14
|
+
stop: systemd.stopRunner,
|
|
15
|
+
programArgs: systemd.programArgs,
|
|
16
|
+
checks: () => [systemd.lingerCheck()],
|
|
17
|
+
};
|
|
18
|
+
export const runnerService = (platform = process.platform) => (platform === 'darwin' ? mac : linux);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Check } from './types.ts';
|
|
2
|
+
export declare const UNIT = "voidshift-agents";
|
|
3
|
+
export declare const servicePath: (home: string) => string;
|
|
4
|
+
export declare const timerPath: (home: string) => string;
|
|
5
|
+
export declare function quote(s: string): string;
|
|
6
|
+
export declare function unquote(s: string): string;
|
|
7
|
+
export declare function serviceUnit(program: string[], env: Record<string, string>, logPath: string): string;
|
|
8
|
+
export declare const timerUnit: () => string;
|
|
9
|
+
export declare function parseExecStart(unit: string): string[];
|
|
10
|
+
export declare const runnerLoaded: () => boolean;
|
|
11
|
+
export declare function startRunner(home: string, program: string[], env: Record<string, string>, logPath: string): boolean;
|
|
12
|
+
export declare function stopRunner(home: string): void;
|
|
13
|
+
export declare function programArgs(home: string): string[] | null;
|
|
14
|
+
export declare function lingerCheck(): Check;
|
package/dist/systemd.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { userInfo } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
// The runner on Linux: a systemd user service that runs one check, and a
|
|
6
|
+
// timer that starts it a minute after the last one finished.
|
|
7
|
+
export const UNIT = 'voidshift-agents';
|
|
8
|
+
const unitDir = (home) => join(process.env.XDG_CONFIG_HOME ?? join(home, '.config'), 'systemd', 'user');
|
|
9
|
+
export const servicePath = (home) => join(unitDir(home), `${UNIT}.service`);
|
|
10
|
+
export const timerPath = (home) => join(unitDir(home), `${UNIT}.timer`);
|
|
11
|
+
// `systemctl --user` needs the user's runtime dir, which a `sudo -iu` or `su`
|
|
12
|
+
// shell does not set.
|
|
13
|
+
function systemctl(args) {
|
|
14
|
+
const env = { ...process.env, XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${userInfo().uid}` };
|
|
15
|
+
const r = spawnSync('systemctl', ['--user', ...args], { encoding: 'utf8', env });
|
|
16
|
+
return { ok: r.status === 0, stdout: (r.stdout ?? '').trim() };
|
|
17
|
+
}
|
|
18
|
+
// One word of a unit file line: quoted, with the characters systemd expands
|
|
19
|
+
// escaped.
|
|
20
|
+
export function quote(s) {
|
|
21
|
+
return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%').replace(/\$/g, '$$$$')}"`;
|
|
22
|
+
}
|
|
23
|
+
export function unquote(s) {
|
|
24
|
+
return s.replace(/\$\$/g, '$').replace(/%%/g, '%').replace(/\\(.)/g, '$1');
|
|
25
|
+
}
|
|
26
|
+
export function serviceUnit(program, env, logPath) {
|
|
27
|
+
const vars = Object.entries(env).map(([k, v]) => `Environment=${quote(`${k}=${v}`)}`);
|
|
28
|
+
return `[Unit]
|
|
29
|
+
Description=VoidShift agents runner, one check for cards to work on
|
|
30
|
+
|
|
31
|
+
[Service]
|
|
32
|
+
Type=oneshot
|
|
33
|
+
ExecStart=${program.map(quote).join(' ')}
|
|
34
|
+
${vars.join('\n')}
|
|
35
|
+
StandardOutput=append:${logPath}
|
|
36
|
+
StandardError=append:${logPath}
|
|
37
|
+
TimeoutStartSec=infinity
|
|
38
|
+
`;
|
|
39
|
+
}
|
|
40
|
+
export const timerUnit = () => `[Unit]
|
|
41
|
+
Description=Starts the VoidShift agents runner every minute
|
|
42
|
+
|
|
43
|
+
[Timer]
|
|
44
|
+
OnActiveSec=5s
|
|
45
|
+
OnUnitInactiveSec=60s
|
|
46
|
+
AccuracySec=1s
|
|
47
|
+
|
|
48
|
+
[Install]
|
|
49
|
+
WantedBy=timers.target
|
|
50
|
+
`;
|
|
51
|
+
// The words of a service unit's ExecStart line.
|
|
52
|
+
export function parseExecStart(unit) {
|
|
53
|
+
const line = /^ExecStart=(.*)$/m.exec(unit)?.[1] ?? '';
|
|
54
|
+
return [...line.matchAll(/"((?:\\.|[^"\\])*)"|(\S+)/g)].map((m) => (m[1] !== undefined ? unquote(m[1]) : m[2]));
|
|
55
|
+
}
|
|
56
|
+
export const runnerLoaded = () => systemctl(['is-active', '--quiet', `${UNIT}.timer`]).ok;
|
|
57
|
+
export function startRunner(home, program, env, logPath) {
|
|
58
|
+
mkdirSync(unitDir(home), { recursive: true });
|
|
59
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
60
|
+
writeFileSync(servicePath(home), serviceUnit(program, env, logPath));
|
|
61
|
+
writeFileSync(timerPath(home), timerUnit());
|
|
62
|
+
if (!systemctl(['daemon-reload']).ok)
|
|
63
|
+
return false;
|
|
64
|
+
systemctl(['restart', `${UNIT}.timer`]);
|
|
65
|
+
return systemctl(['enable', '--now', `${UNIT}.timer`]).ok;
|
|
66
|
+
}
|
|
67
|
+
export function stopRunner(home) {
|
|
68
|
+
systemctl(['disable', '--now', `${UNIT}.timer`]);
|
|
69
|
+
systemctl(['stop', `${UNIT}.service`]);
|
|
70
|
+
rmSync(servicePath(home), { force: true });
|
|
71
|
+
rmSync(timerPath(home), { force: true });
|
|
72
|
+
systemctl(['daemon-reload']);
|
|
73
|
+
}
|
|
74
|
+
export function programArgs(home) {
|
|
75
|
+
return existsSync(servicePath(home)) ? parseExecStart(readFileSync(servicePath(home), 'utf8')) : null;
|
|
76
|
+
}
|
|
77
|
+
// Without lingering, systemd stops the user's timers when they log out and
|
|
78
|
+
// does not start them after a reboot.
|
|
79
|
+
export function lingerCheck() {
|
|
80
|
+
const user = userInfo().username;
|
|
81
|
+
const r = spawnSync('loginctl', ['show-user', user, '--property=Linger', '--value'], { encoding: 'utf8' });
|
|
82
|
+
return r.status === 0 && r.stdout.trim() === 'yes'
|
|
83
|
+
? { name: 'runner after logout', status: 'ok', detail: 'lingering is on, the runner keeps going after logout and reboot' }
|
|
84
|
+
: { name: 'runner after logout', status: 'warn', detail: 'lingering is off, the runner stops when you log out', fix: `sudo loginctl enable-linger ${user}` };
|
|
85
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voidlabs.gg/voidshift-agents",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "Agents for Claude Code and Codex that plan, build and QA Linear cards, chained and run in the background.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"agents",
|
|
19
19
|
"linear",
|
|
20
20
|
"cli",
|
|
21
|
-
"macos"
|
|
21
|
+
"macos",
|
|
22
|
+
"linux"
|
|
22
23
|
],
|
|
23
24
|
"type": "module",
|
|
24
25
|
"main": "dist/index.js",
|
|
@@ -29,9 +30,6 @@
|
|
|
29
30
|
"LICENSE",
|
|
30
31
|
"README.md"
|
|
31
32
|
],
|
|
32
|
-
"os": [
|
|
33
|
-
"darwin"
|
|
34
|
-
],
|
|
35
33
|
"engines": {
|
|
36
34
|
"node": ">=22.18"
|
|
37
35
|
},
|
package/dist/keychain.d.ts
DELETED
package/dist/keychain.js
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import { userInfo } from 'node:os';
|
|
3
|
-
const SERVICE = 'voidshift-linear';
|
|
4
|
-
const KEY_SHAPE = /^lin_api_[A-Za-z0-9]+$/;
|
|
5
|
-
export function readLinearKey() {
|
|
6
|
-
const r = spawnSync('/usr/bin/security', ['find-generic-password', '-s', SERVICE, '-w'], { encoding: 'utf8' });
|
|
7
|
-
const key = r.status === 0 ? r.stdout.trim() : '';
|
|
8
|
-
return key || null;
|
|
9
|
-
}
|
|
10
|
-
export const looksLikeLinearKey = (key) => KEY_SHAPE.test(key);
|
|
11
|
-
// Stores the key through `security -i` on stdin, so it never appears in a
|
|
12
|
-
// process list. The shape check above rules out anything that could break
|
|
13
|
-
// out of the command line.
|
|
14
|
-
export function storeLinearKey(key) {
|
|
15
|
-
if (!looksLikeLinearKey(key))
|
|
16
|
-
return false;
|
|
17
|
-
const command = `add-generic-password -U -s ${SERVICE} -a ${userInfo().username} -w ${key}\n`;
|
|
18
|
-
return spawnSync('/usr/bin/security', ['-i'], { input: command, encoding: 'utf8' }).status === 0;
|
|
19
|
-
}
|