runwork 0.10.2 → 0.10.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.
Files changed (49) hide show
  1. package/dist/agents/__tests__/claude-code-stats.test.js +1 -0
  2. package/dist/agents/claude-code.js +1 -1
  3. package/dist/agents/cursor.js +1 -1
  4. package/dist/commands/clone.js +1 -1
  5. package/dist/commands/deploy.js +1 -1
  6. package/dist/commands/dev.d.ts +3 -0
  7. package/dist/commands/dev.js +628 -11
  8. package/dist/commands/info.d.ts +31 -0
  9. package/dist/commands/info.js +37 -0
  10. package/dist/commands/init.js +1 -1
  11. package/dist/dev/__tests__/attach.test.d.ts +1 -0
  12. package/dist/dev/__tests__/attach.test.js +296 -0
  13. package/dist/dev/__tests__/detach.test.d.ts +1 -0
  14. package/dist/dev/__tests__/detach.test.js +404 -0
  15. package/dist/dev/__tests__/preview-url-poller.test.d.ts +1 -0
  16. package/dist/dev/__tests__/preview-url-poller.test.js +149 -0
  17. package/dist/dev/__tests__/session.test.d.ts +1 -0
  18. package/dist/dev/__tests__/session.test.js +347 -0
  19. package/dist/dev/__tests__/stop.test.d.ts +1 -0
  20. package/dist/dev/__tests__/stop.test.js +172 -0
  21. package/dist/dev/attach.d.ts +120 -0
  22. package/dist/dev/attach.js +269 -0
  23. package/dist/dev/detach.d.ts +187 -0
  24. package/dist/dev/detach.js +292 -0
  25. package/dist/dev/preview-url-poller.d.ts +35 -0
  26. package/dist/dev/preview-url-poller.js +50 -0
  27. package/dist/dev/session.d.ts +158 -0
  28. package/dist/dev/session.js +252 -0
  29. package/dist/dev/stop.d.ts +52 -0
  30. package/dist/dev/stop.js +101 -0
  31. package/dist/generated/version.d.ts +1 -1
  32. package/dist/generated/version.js +1 -1
  33. package/dist/git/__tests__/credentials.test.js +1 -1
  34. package/dist/git/auto-commit.js +1 -1
  35. package/dist/git/credentials.js +1 -1
  36. package/dist/git/identity.js +1 -1
  37. package/dist/git/preflight.js +1 -1
  38. package/dist/git/sync.js +1 -1
  39. package/dist/health/checks.js +1 -1
  40. package/dist/template/manifest.js +1 -1
  41. package/dist/ui/__tests__/keyboard.test.js +4 -0
  42. package/dist/ui/keyboard.d.ts +1 -1
  43. package/dist/ui/keyboard.js +4 -0
  44. package/dist/utils/agent-guidance.d.ts +13 -0
  45. package/dist/utils/agent-guidance.js +22 -7
  46. package/dist/utils/subprocess.d.ts +19 -0
  47. package/dist/utils/subprocess.js +27 -0
  48. package/dist/utils/which.js +1 -1
  49. package/package.json +1 -1
@@ -0,0 +1,269 @@
1
+ /**
2
+ * `runwork dev attach` -- read-only join on a running dev session.
3
+ *
4
+ * Attach is the handover affordance: an agent starts `runwork dev --detach`,
5
+ * a human (or another agent) later runs `runwork dev attach` to see the
6
+ * URL and the live log feed without disturbing the running session.
7
+ *
8
+ * Hard rule: attach NEVER kills the session except in response to an
9
+ * explicit `s` keypress (or `runwork dev stop` in another terminal).
10
+ * Ctrl+C and `q` exit attach but leave the session running. This is the
11
+ * inverted cleanup contract from foreground `runwork dev`, so we keep
12
+ * the keyboard switch local to this module rather than reusing dev.ts's
13
+ * dispatcher.
14
+ */
15
+ import * as fs from 'fs';
16
+ import { getSessionPaths, getSessionState, readSessionFile, removeSessionFile, } from './session.js';
17
+ const identityColors = {
18
+ dim: (s) => s,
19
+ green: (s) => s,
20
+ yellow: (s) => s,
21
+ red: (s) => s,
22
+ cyan: (s) => s,
23
+ };
24
+ /**
25
+ * Turn one raw log-file line into a rendered `RenderedEvent`. The line
26
+ * may be NDJSON (the format the detached child writes) or plain text.
27
+ *
28
+ * - Known NDJSON `event` types get a friendly one-line summary.
29
+ * - Unknown JSON shapes pass through as compact JSON with dim styling.
30
+ * - Non-JSON text passes through verbatim, with stderr lines reddened.
31
+ *
32
+ * Pure function: no I/O, no globals, no color env. Tests pass identity
33
+ * color fns to assert exact output without ANSI noise.
34
+ */
35
+ export function renderLogLine(line, source, colors = identityColors) {
36
+ const trimmed = line.trim();
37
+ if (!trimmed)
38
+ return { text: line, level: 'info' };
39
+ let parsed;
40
+ try {
41
+ parsed = JSON.parse(trimmed);
42
+ }
43
+ catch {
44
+ return {
45
+ text: source === 'stderr' ? colors.red(line) : line,
46
+ level: source === 'stderr' ? 'error' : 'info',
47
+ };
48
+ }
49
+ if (!parsed || typeof parsed !== 'object') {
50
+ return { text: colors.dim(JSON.stringify(parsed)), level: 'info' };
51
+ }
52
+ const ev = parsed.event;
53
+ if (typeof ev !== 'string') {
54
+ return { text: colors.dim(JSON.stringify(parsed)), level: 'info' };
55
+ }
56
+ const obj = parsed;
57
+ switch (ev) {
58
+ case 'session_started': {
59
+ const url = typeof obj.previewUrl === 'string' ? obj.previewUrl : '';
60
+ return {
61
+ text: colors.green('Dev session started') + (url ? ' ' + colors.dim(`(preview ${url})`) : ''),
62
+ level: 'info',
63
+ };
64
+ }
65
+ case 'preview_url_changed': {
66
+ const next = typeof obj.previewUrl === 'string' ? obj.previewUrl : '?';
67
+ const prev = typeof obj.previousUrl === 'string' ? obj.previousUrl : '?';
68
+ return {
69
+ text: colors.yellow('Preview URL changed') + ' ' + colors.dim(`${prev} -> ${next}`),
70
+ level: 'warn',
71
+ };
72
+ }
73
+ case 'files_synced': {
74
+ const count = typeof obj.count === 'number' ? obj.count : 0;
75
+ const target = typeof obj.target === 'string' ? obj.target : 'preview';
76
+ return { text: colors.cyan(`Synced ${count} file(s) -> ${target}`), level: 'info' };
77
+ }
78
+ case 'files_pushed': {
79
+ const count = typeof obj.count === 'number' ? obj.count : 0;
80
+ return { text: colors.cyan(`Pushed ${count} file(s) -> git`), level: 'info' };
81
+ }
82
+ case 'startup': {
83
+ const phase = typeof obj.phase === 'string' ? obj.phase : '';
84
+ return { text: colors.dim(`[startup] ${phase}`), level: 'info' };
85
+ }
86
+ case 'sync_restored_critical_files': {
87
+ const files = Array.isArray(obj.files) ? obj.files.filter((f) => typeof f === 'string').join(', ') : '';
88
+ return { text: colors.yellow(`Restored critical files: ${files}`), level: 'warn' };
89
+ }
90
+ case 'error': {
91
+ const errObj = obj.error;
92
+ const msg = typeof errObj?.message === 'string' ? errObj.message : 'unknown error';
93
+ const phase = typeof obj.phase === 'string' ? obj.phase : 'unknown';
94
+ const diag = typeof errObj?.diagnosis === 'string' ? `\n ${colors.dim(errObj.diagnosis)}` : '';
95
+ return { text: colors.red(`ERROR (${phase}): ${msg}${diag}`), level: 'error' };
96
+ }
97
+ default:
98
+ return { text: colors.dim(JSON.stringify(parsed)), level: 'info' };
99
+ }
100
+ }
101
+ /**
102
+ * Poll-based tail of stdout/stderr log files. Cross-platform by design
103
+ * (no `fs.watch`, no native deps, identical behavior on macOS/Linux/
104
+ * Windows). Reads the last `initialLines` lines on start to give the
105
+ * attaching user immediate context, then watches for appends.
106
+ */
107
+ export function startLogTail(stdoutPath, stderrPath, opts) {
108
+ const intervalMs = opts.intervalMs ?? 500;
109
+ const initialLines = opts.initialLines ?? 50;
110
+ let stopped = false;
111
+ const offsets = { stdout: 0, stderr: 0 };
112
+ const paths = { stdout: stdoutPath, stderr: stderrPath };
113
+ // Initial replay: last N lines from each file (most recent at the bottom).
114
+ for (const source of ['stdout', 'stderr']) {
115
+ const p = paths[source];
116
+ if (!fs.existsSync(p))
117
+ continue;
118
+ try {
119
+ const stat = fs.statSync(p);
120
+ const buf = fs.readFileSync(p, 'utf-8');
121
+ const lines = buf.split('\n');
122
+ // Strip the trailing empty entry from a final `\n`.
123
+ const cleaned = lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines;
124
+ const tail = cleaned.slice(-initialLines);
125
+ for (const line of tail) {
126
+ opts.onLine(line, source);
127
+ }
128
+ offsets[source] = stat.size;
129
+ }
130
+ catch (err) {
131
+ opts.onError?.(err);
132
+ }
133
+ }
134
+ const tickOne = (source) => {
135
+ const p = paths[source];
136
+ if (!fs.existsSync(p))
137
+ return;
138
+ let stat;
139
+ try {
140
+ stat = fs.statSync(p);
141
+ }
142
+ catch (err) {
143
+ opts.onError?.(err);
144
+ return;
145
+ }
146
+ if (stat.size < offsets[source]) {
147
+ // Truncation: a new dev session started and re-opened the log file
148
+ // in 'w' mode. Tell the caller; they decide whether to exit or
149
+ // reset offsets and continue.
150
+ opts.onTruncated?.();
151
+ offsets[source] = 0;
152
+ return;
153
+ }
154
+ if (stat.size === offsets[source])
155
+ return;
156
+ try {
157
+ const fd = fs.openSync(p, 'r');
158
+ try {
159
+ const length = stat.size - offsets[source];
160
+ const buf = Buffer.alloc(length);
161
+ fs.readSync(fd, buf, 0, length, offsets[source]);
162
+ offsets[source] = stat.size;
163
+ const text = buf.toString('utf-8');
164
+ const lines = text.split('\n');
165
+ const cleaned = lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines;
166
+ for (const line of cleaned) {
167
+ opts.onLine(line, source);
168
+ }
169
+ }
170
+ finally {
171
+ fs.closeSync(fd);
172
+ }
173
+ }
174
+ catch (err) {
175
+ opts.onError?.(err);
176
+ }
177
+ };
178
+ const handle = setInterval(() => {
179
+ if (stopped)
180
+ return;
181
+ tickOne('stdout');
182
+ tickOne('stderr');
183
+ }, intervalMs);
184
+ return {
185
+ stop: () => {
186
+ stopped = true;
187
+ clearInterval(handle);
188
+ },
189
+ };
190
+ }
191
+ /**
192
+ * Format a "started X ago" string for human display. Pure function, no
193
+ * locale handling -- this is a developer tool, not a UI.
194
+ */
195
+ export function formatStartedAgo(startedAt, now = Date.now()) {
196
+ const sec = Math.max(0, Math.round((now - startedAt) / 1000));
197
+ if (sec < 60)
198
+ return `${sec}s ago`;
199
+ if (sec < 3600)
200
+ return `${Math.floor(sec / 60)}m ago`;
201
+ return `${Math.floor(sec / 3600)}h ago`;
202
+ }
203
+ export function startSessionFileWatch(appDir, initial, opts) {
204
+ const intervalMs = opts.intervalMs ?? 2_000;
205
+ let stopped = false;
206
+ let lastUrl = initial.previewUrl;
207
+ const lastPid = initial.pid;
208
+ const handle = setInterval(() => {
209
+ if (stopped)
210
+ return;
211
+ const file = readSessionFile(appDir);
212
+ if (!file) {
213
+ // The file disappeared. Either the session ended cleanly (its
214
+ // cleanup handler removed it) or someone called `dev stop`. Either
215
+ // way, the session this attach was tracking is gone.
216
+ opts.onSessionGone?.();
217
+ return;
218
+ }
219
+ if (file.appId !== opts.expectedAppId || file.pid !== lastPid) {
220
+ // A different session took over (e.g., user did `dev --restart`
221
+ // somewhere). Treat the original session as gone -- attach was
222
+ // following a specific PID, not the whole app.
223
+ opts.onSessionGone?.();
224
+ return;
225
+ }
226
+ if (file.previewUrl && file.previewUrl !== lastUrl) {
227
+ const prev = lastUrl;
228
+ lastUrl = file.previewUrl;
229
+ opts.onUrlChanged?.(file.previewUrl, prev);
230
+ }
231
+ }, intervalMs);
232
+ return {
233
+ stop: () => {
234
+ stopped = true;
235
+ clearInterval(handle);
236
+ },
237
+ };
238
+ }
239
+ /**
240
+ * Resolve what attach can/should do based on the session file alone.
241
+ * Pure logic; the caller decides what to render and whether to keep
242
+ * running. Split out so the resolution can be unit-tested separately
243
+ * from the long-running tail loop.
244
+ */
245
+ export function resolveAttachTarget(appDir, expectedAppId) {
246
+ const state = getSessionState(appDir, expectedAppId);
247
+ if (state.state === 'none')
248
+ return { result: 'no-session' };
249
+ if (state.state === 'stale') {
250
+ removeSessionFile(appDir);
251
+ return { result: 'stale-cleaned', reason: state.reason };
252
+ }
253
+ return { result: 'attached', file: state.file };
254
+ }
255
+ /**
256
+ * Helper exposed for the orchestrator: returns the current preview URL
257
+ * the user should "open" if they press `o`. We re-read the session file
258
+ * each time so URL rotations are honored without plumbing watchers
259
+ * through the keyboard handler.
260
+ */
261
+ export function getCurrentPreviewUrl(appDir, fallback) {
262
+ const file = readSessionFile(appDir);
263
+ return file?.previewUrl || fallback;
264
+ }
265
+ /** Convenience: the log file paths for the given app dir. */
266
+ export function getAttachLogPaths(appDir) {
267
+ const paths = getSessionPaths(appDir);
268
+ return { stdout: paths.stdoutLog, stderr: paths.stderrLog };
269
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * `runwork dev --detach` orchestration.
3
+ *
4
+ * The detach contract:
5
+ *
6
+ * 1. Parent spawns the child via `process.execPath` with the same args
7
+ * plus `--internal-detached-child`. Child stdout/stderr are routed to
8
+ * log files in `.runwork/`. Parent calls `child.unref()` and exits as
9
+ * soon as the rendezvous signal arrives.
10
+ *
11
+ * 2. Child runs the same `runwork dev` code path as foreground, but in
12
+ * "detached" mode: no TUI, no keypress listener, JSON event stream
13
+ * to stdout (which is the log file).
14
+ *
15
+ * 3. Rendezvous channel is the session file, NOT a pipe. The child
16
+ * writes `.runwork/dev-session.json` once it has a `previewUrl`. The
17
+ * parent polls. When the parent observes a file whose `pid` matches
18
+ * the spawned child AND `previewUrl` is non-empty, parent prints +
19
+ * emits the URL and exits 0.
20
+ *
21
+ * 4. On timeout, parent attempts a single kill of the child, removes
22
+ * any partial session file, and exits 1 with a structured error.
23
+ *
24
+ * The file-as-IPC choice is intentional: pipes don't survive parent exit,
25
+ * and Node IPC has historical Bun-on-Windows quirks. The session file is
26
+ * already our durable lifecycle truth; reusing it for the handshake is
27
+ * free.
28
+ */
29
+ import { type SessionDeps, type SessionFile } from './session.js';
30
+ /**
31
+ * Internal flag passed to the child. Hidden from `--help`. The child uses
32
+ * its presence to skip the parent-spawn branch and run the actual dev
33
+ * work in detached mode.
34
+ */
35
+ export declare const INTERNAL_DETACHED_CHILD_FLAG = "--internal-detached-child";
36
+ export type PollOutcome = {
37
+ result: 'ready';
38
+ file: SessionFile;
39
+ } | {
40
+ result: 'timeout';
41
+ } | {
42
+ result: 'wrong-pid';
43
+ file: SessionFile;
44
+ } | {
45
+ result: 'child-exited';
46
+ };
47
+ export interface PollDeps extends SessionDeps {
48
+ now?: () => number;
49
+ sleep?: (ms: number) => Promise<void>;
50
+ }
51
+ /**
52
+ * Poll the session file until either:
53
+ * - a file appears whose pid matches `expectedPid` AND has a non-empty
54
+ * `previewUrl` -> `ready`
55
+ * - the timeout elapses -> `timeout`
56
+ *
57
+ * If a file appears with a different pid (some other process won a write
58
+ * race), we treat it as the user's intent being satisfied and return
59
+ * `wrong-pid` so the caller can decide how to handle it -- usually that's
60
+ * "another `runwork dev` invocation already had a session running, fine."
61
+ *
62
+ * The function does not validate `bootTime` or `appId` -- those are the
63
+ * caller's job once they know which file matters. We only care about the
64
+ * shape of the file and the pid match here.
65
+ */
66
+ export declare function pollForSession(appDir: string, expectedPid: number, expectedAppId: string, opts?: {
67
+ intervalMs?: number;
68
+ timeoutMs?: number;
69
+ deps?: PollDeps;
70
+ /**
71
+ * Optional: probe whether the spawned child is still running. If
72
+ * provided and it ever returns false, polling aborts immediately
73
+ * with `child-exited`. Lets the caller distinguish "child died
74
+ * before writing the file" (fast fail, ~one tick) from "child is
75
+ * just slow" (wait for timeout). Without this hook the parent
76
+ * would hang for the full timeout on a fast-failing child.
77
+ */
78
+ isChildAlive?: () => boolean;
79
+ }): Promise<PollOutcome>;
80
+ /**
81
+ * Open the stdout/stderr log files in truncating mode and return their
82
+ * descriptors. We truncate (not append) so each new dev session starts
83
+ * fresh -- log rotation across sessions is out of scope.
84
+ *
85
+ * The descriptors must be closed by the caller after `spawn` so the
86
+ * parent process doesn't keep them open (which would prevent the child
87
+ * from being the sole owner).
88
+ */
89
+ export declare function openLogFds(appDir: string): {
90
+ stdoutFd: number;
91
+ stderrFd: number;
92
+ };
93
+ export interface SpawnedChildHandle {
94
+ pid: number;
95
+ kill: (signal?: NodeJS.Signals) => boolean;
96
+ /**
97
+ * Returns false once the child has exited. Lets `pollForSession`
98
+ * abort early instead of waiting the full timeout when the child
99
+ * fails fast (missing auth, sync conflict, sandbox-boot error).
100
+ */
101
+ isAlive: () => boolean;
102
+ }
103
+ export interface DetachParentOptions {
104
+ appDir: string;
105
+ expectedAppId: string;
106
+ childArgs: string[];
107
+ intervalMs?: number;
108
+ timeoutMs?: number;
109
+ /**
110
+ * Optional spawn override for tests. Real callers pass nothing and get
111
+ * the cross-platform spawn defined below.
112
+ */
113
+ spawn?: (args: string[]) => SpawnedChildHandle;
114
+ pollDeps?: PollDeps;
115
+ }
116
+ export type DetachParentOutcome = {
117
+ result: 'started';
118
+ file: SessionFile;
119
+ } | {
120
+ result: 'wrong-pid';
121
+ file: SessionFile;
122
+ ourPid: number;
123
+ } | {
124
+ result: 'timeout';
125
+ ourPid: number;
126
+ childLogTail?: string;
127
+ } | {
128
+ result: 'child-exited';
129
+ ourPid: number;
130
+ childLogTail?: string;
131
+ } | {
132
+ result: 'spawn-failed';
133
+ error: unknown;
134
+ };
135
+ /**
136
+ * The parent half of `runwork dev --detach`. Returns a structured outcome
137
+ * the caller can translate into stdout text and an exit code.
138
+ *
139
+ * IMPORTANT: this function does NOT print anything. It only orchestrates.
140
+ * The caller (in `dev.ts`) decides how to render the outcome -- human
141
+ * banner vs. JSON event vs. error stream.
142
+ */
143
+ export declare function runAsDetachedParent(opts: DetachParentOptions): Promise<DetachParentOutcome>;
144
+ /**
145
+ * Cross-platform detached self-spawn. Uses `process.execPath` so we never
146
+ * depend on PATH lookup -- a known failure mode for Bun standalone on
147
+ * Windows. The child gets the user-supplied args plus the internal child
148
+ * marker.
149
+ */
150
+ export declare function defaultSpawnDetachedChild(childArgs: string[]): SpawnedChildHandle;
151
+ /**
152
+ * Detect whether the current `runwork dev` invocation is the detached
153
+ * child. Used by `dev.ts` to pick between the parent-spawn branch and
154
+ * the actual dev work.
155
+ */
156
+ export declare function isInternalDetachedChild(argv?: readonly string[]): boolean;
157
+ /**
158
+ * Strip the internal marker from a list of args. Used when constructing
159
+ * the child's args from the parent's own args -- the parent already has
160
+ * the marker, the child needs it, but we want to avoid duplicates if for
161
+ * any reason the parent was itself launched with the marker (e.g., a
162
+ * misconfigured wrapper).
163
+ */
164
+ export declare function stripInternalChildFlag(args: readonly string[]): string[];
165
+ /**
166
+ * Detect a Bun standalone virtual-filesystem path. Bun's compile mode
167
+ * on Windows injects the in-bundle script path as `process.argv[1]`
168
+ * (e.g., `B:/~BUN/root/runwork-windows-x64.exe`). When we self-spawn,
169
+ * the child Bun runtime re-injects an equivalent entry on its own --
170
+ * forwarding ours causes a duplicate that downstream parsers (commander
171
+ * here) misread as a stray positional command. macOS and Linux Bun
172
+ * standalone do NOT inject this entry, but the prefix is documented in
173
+ * Bun source as `/$bunfs/` if it ever appears, so we detect that too
174
+ * defensively.
175
+ */
176
+ export declare function looksLikeBunStandaloneArtifact(p: string): boolean;
177
+ /**
178
+ * Construct the args we should forward to the spawned child so it
179
+ * re-runs the same `runwork dev` invocation as the parent. Drops
180
+ * elements that the child runtime will re-inject on its own (notably
181
+ * the Bun-on-Windows virtual-FS path) and drops any pre-existing copy
182
+ * of the internal-child marker before we re-add exactly one.
183
+ *
184
+ * Pure function for testability -- accepts the parent's argv and returns
185
+ * what to hand to `spawn`. Real callers pass `process.argv`.
186
+ */
187
+ export declare function buildChildArgs(parentArgv: readonly string[]): string[];