runwork 0.3.0 → 0.4.0

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.
@@ -1,4 +1,4 @@
1
- import type { Credentials, AppInfo, WorkspaceInfo, DevSession } from '../types.js';
1
+ import type { Credentials, AppInfo, WorkspaceInfo, DevSession, WorkspaceAllData } from '../types.js';
2
2
  export declare class ApiClient {
3
3
  private baseUrl;
4
4
  private apiKey;
@@ -90,5 +90,6 @@ export declare class ApiClient {
90
90
  * Both canonical ({baseUrl}/api/git/{workspaceId}/{appId}) and
91
91
  * subdomain ({slug}.runwork.ai/api/git/{appId}) formats are supported.
92
92
  */
93
+ getWorkspaceAll(workspaceId: string): Promise<WorkspaceAllData>;
93
94
  getGitRemoteUrl(workspaceId: string, appId: string): string;
94
95
  }
@@ -147,6 +147,10 @@ export class ApiClient {
147
147
  * Both canonical ({baseUrl}/api/git/{workspaceId}/{appId}) and
148
148
  * subdomain ({slug}.runwork.ai/api/git/{appId}) formats are supported.
149
149
  */
150
+ async getWorkspaceAll(workspaceId) {
151
+ const res = await this.request(`/api/workspaces/${workspaceId}/all`);
152
+ return res.data;
153
+ }
150
154
  getGitRemoteUrl(workspaceId, appId) {
151
155
  return `${this.baseUrl}/api/git/${workspaceId}/${appId}`;
152
156
  }
@@ -3,10 +3,16 @@ import { execFileSync } from 'child_process';
3
3
  import { readFileSync, existsSync } from 'fs';
4
4
  import { requireAuth } from '../auth/store.js';
5
5
  import { ApiClient } from '../api/client.js';
6
+ import { shouldOutputJson, jsonOut } from '../utils/output.js';
6
7
  export const deployCommand = new Command('deploy')
7
- .description('Deploy the current app to Runwork')
8
- .action(async () => {
8
+ .description('Deploy the current app to production')
9
+ .action(async (_opts, command) => {
10
+ const useJson = shouldOutputJson(command.optsWithGlobals().json);
9
11
  if (!existsSync('.runwork.json')) {
12
+ if (useJson) {
13
+ process.stderr.write(JSON.stringify({ error: 'No .runwork.json found. Run `runwork init` first.' }) + '\n');
14
+ process.exit(1);
15
+ }
10
16
  console.error('No .runwork.json found. Run `runwork init` first.');
11
17
  process.exit(1);
12
18
  }
@@ -23,21 +29,32 @@ export const deployCommand = new Command('deploy')
23
29
  // No commits yet
24
30
  }
25
31
  if (repoHasCommits) {
26
- console.log('Syncing...');
32
+ if (!useJson)
33
+ console.log('Syncing...');
27
34
  try {
28
- execFileSync('git', ['pull', '--rebase', 'runwork', 'main'], { stdio: 'inherit' });
35
+ execFileSync('git', ['pull', '--rebase', 'runwork', 'main'], { stdio: useJson ? 'pipe' : 'inherit' });
29
36
  }
30
37
  catch {
31
- console.warn('Pull failed. Continuing...');
38
+ if (!useJson)
39
+ console.warn('Pull failed. Continuing...');
32
40
  }
33
- execFileSync('git', ['push', 'runwork', 'main'], { stdio: 'inherit' });
41
+ execFileSync('git', ['push', 'runwork', 'main'], { stdio: useJson ? 'pipe' : 'inherit' });
34
42
  }
35
43
  else {
44
+ if (useJson) {
45
+ process.stderr.write(JSON.stringify({ error: 'No commits to deploy. Commit your changes first.' }) + '\n');
46
+ process.exit(1);
47
+ }
36
48
  console.error('No commits to deploy. Commit your changes first.');
37
49
  process.exit(1);
38
50
  }
39
51
  // Trigger deploy
40
- console.log('Deploying...');
52
+ if (!useJson)
53
+ console.log('Deploying...');
41
54
  const result = await client.triggerDeploy(config.appId);
55
+ if (useJson) {
56
+ jsonOut({ deployed: true, url: result.deploymentUrl });
57
+ return;
58
+ }
42
59
  console.log(`Deployed: ${result.deploymentUrl}`);
43
60
  });
@@ -2,5 +2,6 @@ import { Command } from 'commander';
2
2
  export declare function execDev(options?: {
3
3
  logs?: boolean;
4
4
  logsOnlyFile?: boolean;
5
+ json?: boolean;
5
6
  }): Promise<void>;
6
7
  export declare const devCommand: Command;
@@ -15,6 +15,7 @@ import { getDevBanner, getKeyboardHints, getInfoPanel } from '../ui/banner.js';
15
15
  import { createStatusLine } from '../ui/status-line.js';
16
16
  import { createKeyboardListener } from '../ui/keyboard.js';
17
17
  import { bold, dim, green, yellow, cyan } from '../ui/colors.js';
18
+ import { shouldOutputJson, jsonLine } from '../utils/output.js';
18
19
  async function populateSkill(projectDir, client, appId) {
19
20
  try {
20
21
  const { skill } = await client.getAppSkill(appId);
@@ -34,18 +35,25 @@ function readConfig() {
34
35
  return JSON.parse(readFileSync('.runwork.json', 'utf-8'));
35
36
  }
36
37
  export async function execDev(options) {
38
+ const useJson = options?.json ?? false;
37
39
  const config = readConfig();
38
40
  const creds = requireAuth();
39
41
  const client = new ApiClient(creds);
40
42
  const cwd = process.cwd();
43
+ const ts = () => new Date().toISOString();
41
44
  // Detect user edits made outside of `runwork dev`
42
45
  const oldManifest = await loadManifest(cwd);
43
46
  if (oldManifest) {
44
47
  const userEdits = await detectUserEdits(cwd, oldManifest);
45
48
  if (userEdits.length > 0) {
46
- console.log(`Detected ${userEdits.length} file(s) edited outside dev session:`);
47
- for (const file of userEdits) {
48
- console.log(` ${file}`);
49
+ if (useJson) {
50
+ jsonLine({ event: 'startup', phase: 'user_edits_detected', files: userEdits, timestamp: ts() });
51
+ }
52
+ else {
53
+ console.log(`Detected ${userEdits.length} file(s) edited outside dev session:`);
54
+ for (const file of userEdits) {
55
+ console.log(` ${file}`);
56
+ }
49
57
  }
50
58
  try {
51
59
  execFileSync('git', ['add', '--', ...userEdits], { stdio: 'pipe' });
@@ -57,15 +65,18 @@ export async function execDev(options) {
57
65
  }
58
66
  }
59
67
  // Download fresh template to ensure latest version
60
- console.log(dim('Updating template...'));
68
+ if (useJson) {
69
+ jsonLine({ event: 'startup', phase: 'template_update', timestamp: ts() });
70
+ }
71
+ else {
72
+ console.log(dim('Updating template...'));
73
+ }
61
74
  try {
62
75
  const zipData = await client.downloadSkeleton();
63
76
  extractZip(zipData, cwd);
64
77
  removeNestedGitDirs(cwd);
65
- // Generate new manifest from fresh template (before restoring user files)
66
78
  const newManifest = await generateManifest(cwd);
67
79
  await saveManifest(cwd, newManifest);
68
- // Restore user-edited files from git on top of fresh template
69
80
  try {
70
81
  execFileSync('git', ['checkout', '--', '.'], { cwd, stdio: 'pipe' });
71
82
  }
@@ -74,116 +85,139 @@ export async function execDev(options) {
74
85
  }
75
86
  }
76
87
  catch {
77
- console.warn(yellow('Template update failed. Continuing with current files.'));
88
+ if (!useJson)
89
+ console.warn(yellow('Template update failed. Continuing with current files.'));
78
90
  }
79
91
  // Populate type definitions
80
92
  await populateTypes(cwd);
81
- // Start dev session first - this triggers DO onStart which may create remote commits
82
- console.log(dim('Starting dev session...'));
93
+ if (useJson)
94
+ jsonLine({ event: 'startup', phase: 'types_populated', timestamp: ts() });
95
+ // Start dev session
96
+ if (useJson) {
97
+ jsonLine({ event: 'startup', phase: 'dev_session', timestamp: ts() });
98
+ }
99
+ else {
100
+ console.log(dim('Starting dev session...'));
101
+ }
83
102
  const session = await client.startDevSession(config.appId);
84
- // Fetch SKILL.md (best-effort, after session so DO registries are available)
103
+ // Fetch SKILL.md (best-effort)
85
104
  await populateSkill(cwd, client, config.appId);
86
- // Sync AFTER starting session so we pick up any commits the DO created
87
- console.log(dim('Syncing...'));
105
+ if (useJson)
106
+ jsonLine({ event: 'startup', phase: 'skill_fetched', timestamp: ts() });
107
+ // Sync
108
+ if (!useJson)
109
+ console.log(dim('Syncing...'));
88
110
  const syncResult = syncWithRemote(cwd);
89
- switch (syncResult.status) {
90
- case 'skipped':
91
- console.log(dim(' No commits yet. Skipping sync.'));
92
- break;
93
- case 'synced':
94
- console.log(green(' Synced with Runwork.'));
95
- break;
96
- case 'merged':
97
- console.log(yellow(' Merged with Runwork (histories diverged).'));
98
- break;
99
- case 'sync-failed':
100
- if (syncResult.error === 'stash-conflict') {
101
- console.error('Merge conflicts detected after stash pop. Resolve conflicts and restart.');
102
- process.exit(1);
103
- }
104
- console.warn(yellow(`Sync failed. Continuing...`));
105
- if (syncResult.error) {
106
- console.warn(dim(` ${syncResult.error}`));
107
- }
108
- break;
109
- }
110
- if (!syncResult.pushed && syncResult.status !== 'skipped' && syncResult.status !== 'sync-failed') {
111
- console.warn(yellow('Push failed. Continuing with current state...'));
112
- }
113
- // Show banner
114
- console.log(getDevBanner({ appName: config.appName, previewUrl: session.previewUrl, workspaceName: config.workspaceName }));
115
- console.log(getKeyboardHints());
116
- console.log('');
117
- console.log(dim(' ─────────────────────────────────────────────'));
118
- console.log('');
119
- // Status line tracks sync activity
120
- const statusLine = createStatusLine(process.stdout);
111
+ if (useJson) {
112
+ jsonLine({ event: 'startup', phase: 'sync', status: syncResult.status, pushed: syncResult.pushed, timestamp: ts() });
113
+ }
114
+ else {
115
+ switch (syncResult.status) {
116
+ case 'skipped':
117
+ console.log(dim(' No commits yet. Skipping sync.'));
118
+ break;
119
+ case 'synced':
120
+ console.log(green(' Synced with Runwork.'));
121
+ break;
122
+ case 'merged':
123
+ console.log(yellow(' Merged with Runwork (histories diverged).'));
124
+ break;
125
+ case 'sync-failed':
126
+ if (syncResult.error === 'stash-conflict') {
127
+ console.error('Merge conflicts detected after stash pop. Resolve conflicts and restart.');
128
+ process.exit(1);
129
+ }
130
+ console.warn(yellow(`Sync failed. Continuing...`));
131
+ if (syncResult.error) {
132
+ console.warn(dim(` ${syncResult.error}`));
133
+ }
134
+ break;
135
+ }
136
+ if (!syncResult.pushed && syncResult.status !== 'skipped' && syncResult.status !== 'sync-failed') {
137
+ console.warn(yellow('Push failed. Continuing with current state...'));
138
+ }
139
+ }
140
+ // Emit session_started (JSON) or show banner (human)
141
+ if (useJson) {
142
+ jsonLine({ event: 'session_started', previewUrl: session.previewUrl, appName: config.appName, timestamp: ts() });
143
+ }
144
+ else {
145
+ console.log(getDevBanner({ appName: config.appName, previewUrl: session.previewUrl, workspaceName: config.workspaceName }));
146
+ console.log(getKeyboardHints());
147
+ console.log('');
148
+ console.log(dim(' ─────────────────────────────────────────────'));
149
+ console.log('');
150
+ }
151
+ // Status line and keyboard only in human mode
152
+ const statusLine = useJson ? null : createStatusLine(process.stdout);
121
153
  let syncedCount = 0;
122
154
  let logFilter = 'all';
123
155
  function updateStatus() {
156
+ if (!statusLine)
157
+ return;
124
158
  const filterLabel = logFilter === 'all' ? 'All' : logFilter === 'events' ? 'Events' : 'Runtime';
125
- const sep = '\x1b[90m\u2502\x1b[39m'; // dim │
159
+ const sep = '\x1b[90m\u2502\x1b[39m';
126
160
  statusLine.update(`${bold(config.appName)} ${sep} ${green(session.previewUrl)} ${sep} ${cyan(`${syncedCount} synced`)} ${sep} ${filterLabel} ${sep} \x1b[90mo\x1b[39m:open \x1b[90mi\x1b[39m:info \x1b[90ma/e/r\x1b[39m:filter \x1b[90mq\x1b[39m:quit`);
127
161
  }
128
- updateStatus();
129
- // Declare logTailer before cleanup so cleanup can reference it
162
+ if (statusLine)
163
+ updateStatus();
130
164
  let logTailer;
131
- const keyboard = createKeyboardListener();
132
- // Register cleanup before starting watcher to avoid race
165
+ const keyboard = useJson ? null : createKeyboardListener();
133
166
  const cleanup = async () => {
134
- console.log('\nStopping...');
167
+ if (!useJson)
168
+ console.log('\nStopping...');
135
169
  logTailer?.stop();
136
170
  await stopAutoCommit();
137
- statusLine.destroy();
138
- keyboard.stop(); // Last - stopping stdin can affect process exit
171
+ statusLine?.destroy();
172
+ keyboard?.stop();
139
173
  process.exit(0);
140
174
  };
141
175
  process.on('SIGINT', cleanup);
142
176
  process.on('SIGTERM', cleanup);
143
- keyboard.start((action) => {
144
- switch (action) {
145
- case 'o':
146
- case 'p':
147
- import('open').then(m => m.default(session.previewUrl));
148
- break;
149
- case 'a':
150
- logFilter = 'all';
151
- console.log(dim(' Showing all logs.'));
152
- updateStatus();
153
- break;
154
- case 'e':
155
- logFilter = 'events';
156
- console.log(dim(' Showing events only.'));
157
- updateStatus();
158
- break;
159
- case 'r':
160
- logFilter = 'runtime';
161
- console.log(dim(' Showing runtime logs only.'));
162
- updateStatus();
163
- break;
164
- case 'i':
165
- console.log(getInfoPanel({
166
- appName: config.appName,
167
- previewUrl: session.previewUrl,
168
- workspaceName: config.workspaceName,
169
- directory: cwd,
170
- }));
171
- break;
172
- case 'quit':
173
- cleanup();
174
- break;
175
- }
176
- });
177
+ if (keyboard) {
178
+ keyboard.start((action) => {
179
+ switch (action) {
180
+ case 'o':
181
+ case 'p':
182
+ import('open').then(m => m.default(session.previewUrl));
183
+ break;
184
+ case 'a':
185
+ logFilter = 'all';
186
+ console.log(dim(' Showing all logs.'));
187
+ updateStatus();
188
+ break;
189
+ case 'e':
190
+ logFilter = 'events';
191
+ console.log(dim(' Showing events only.'));
192
+ updateStatus();
193
+ break;
194
+ case 'r':
195
+ logFilter = 'runtime';
196
+ console.log(dim(' Showing runtime logs only.'));
197
+ updateStatus();
198
+ break;
199
+ case 'i':
200
+ console.log(getInfoPanel({
201
+ appName: config.appName,
202
+ previewUrl: session.previewUrl,
203
+ workspaceName: config.workspaceName,
204
+ directory: cwd,
205
+ }));
206
+ break;
207
+ case 'quit':
208
+ cleanup();
209
+ break;
210
+ }
211
+ });
212
+ }
177
213
  // Start file watcher
178
214
  await watchAndAutoCommit(process.cwd(), client, config.appId, {
179
- onFastSync(count) {
180
- syncedCount += count;
181
- updateStatus();
182
- },
183
- onGitPush(count) {
184
- syncedCount += count;
185
- updateStatus();
186
- },
215
+ onFastSync: useJson
216
+ ? (count) => { jsonLine({ event: 'files_synced', count, target: 'preview', timestamp: ts() }); }
217
+ : (count) => { syncedCount += count; updateStatus(); },
218
+ onGitPush: useJson
219
+ ? (count) => { jsonLine({ event: 'files_pushed', count, target: 'git', timestamp: ts() }); }
220
+ : (count) => { syncedCount += count; updateStatus(); },
187
221
  });
188
222
  // Start log tailing (unless --no-logs)
189
223
  if (options?.logs !== false) {
@@ -194,13 +228,17 @@ export async function execDev(options) {
194
228
  toTerminal: !options?.logsOnlyFile,
195
229
  toFile: true,
196
230
  getFilter: () => logFilter,
231
+ useJson,
197
232
  });
198
233
  }
199
234
  // Keep alive
200
235
  await new Promise(() => { });
201
236
  }
202
237
  export const devCommand = new Command('dev')
203
- .description('Start local development server with live sync')
238
+ .description('Start local development with live sync, preview sandbox, and file watching')
204
239
  .option('--no-logs', 'Disable automatic log tailing')
205
240
  .option('--logs-only-file', 'Write logs to file only, not terminal')
206
- .action(async (options) => execDev(options));
241
+ .action(async (options, command) => {
242
+ const globalJson = shouldOutputJson(command.optsWithGlobals().json);
243
+ await execDev({ ...options, json: globalJson });
244
+ });
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare const infoCommand: Command;