@playcraft/cli 0.1.6 → 0.1.8
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.
|
@@ -2,6 +2,11 @@ import { connect as connectTcp } from 'node:net';
|
|
|
2
2
|
import { WebSocket, WebSocketServer } from 'ws';
|
|
3
3
|
import { desktopHealthSchema, desktopResourceName, runtimeError, } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
4
|
import { controlTokenMatches, extractControlToken } from './auth.js';
|
|
5
|
+
/**
|
|
6
|
+
* A takeover holds the desktop for this long, whether or not a page is watching, and ends on its
|
|
7
|
+
* own unless the user renews it.
|
|
8
|
+
*/
|
|
9
|
+
const HOLD_MS = 20 * 60_000;
|
|
5
10
|
const sources = new WeakMap();
|
|
6
11
|
const hosts = new WeakMap();
|
|
7
12
|
export function setDesktopSource(state, source) {
|
|
@@ -33,6 +38,9 @@ export class DesktopHost {
|
|
|
33
38
|
sockets = new WebSocketServer({ noServer: true });
|
|
34
39
|
source;
|
|
35
40
|
upstream = loopbackUpstream(process.env.PLAYCRAFT_DESKTOP_UPSTREAM);
|
|
41
|
+
holdMs = holdDuration(process.env.PLAYCRAFT_DESKTOP_HOLD_MS);
|
|
42
|
+
holdExpiresAt;
|
|
43
|
+
holdTimer;
|
|
36
44
|
constructor(state) {
|
|
37
45
|
this.state = state;
|
|
38
46
|
this.source = sources.get(state);
|
|
@@ -46,6 +54,9 @@ export class DesktopHost {
|
|
|
46
54
|
streamGeneration: this.generation,
|
|
47
55
|
holder: this.holder,
|
|
48
56
|
agentInput: this.holder === 'human' ? 'revoked' : 'open',
|
|
57
|
+
...(this.holder === 'human' && this.holdExpiresAt
|
|
58
|
+
? { holdExpiresAt: new Date(this.holdExpiresAt).toISOString() }
|
|
59
|
+
: {}),
|
|
49
60
|
});
|
|
50
61
|
}
|
|
51
62
|
async refresh() {
|
|
@@ -71,12 +82,21 @@ export class DesktopHost {
|
|
|
71
82
|
}
|
|
72
83
|
this.holder = 'human';
|
|
73
84
|
this.cutViewers();
|
|
85
|
+
this.hold();
|
|
86
|
+
writeJson(response, 200, this.snapshot());
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (request.method === 'POST' && url.pathname === '/runtime/desktop/renew') {
|
|
90
|
+
if (this.holder !== 'human') {
|
|
91
|
+
writeError(response, 'desktop_not_held');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
this.hold();
|
|
74
95
|
writeJson(response, 200, this.snapshot());
|
|
75
96
|
return;
|
|
76
97
|
}
|
|
77
98
|
if (request.method === 'DELETE' && url.pathname === '/runtime/desktop') {
|
|
78
|
-
this.
|
|
79
|
-
this.cutViewers();
|
|
99
|
+
this.release();
|
|
80
100
|
writeJson(response, 200, this.snapshot());
|
|
81
101
|
return;
|
|
82
102
|
}
|
|
@@ -114,9 +134,30 @@ export class DesktopHost {
|
|
|
114
134
|
this.sockets.handleUpgrade(request, socket, head, (ws) => this.attach(ws, generation));
|
|
115
135
|
}
|
|
116
136
|
dispose() {
|
|
137
|
+
this.release();
|
|
138
|
+
this.sockets.close();
|
|
139
|
+
}
|
|
140
|
+
release() {
|
|
141
|
+
this.clearHold();
|
|
117
142
|
this.holder = 'none';
|
|
118
143
|
this.cutViewers();
|
|
119
|
-
|
|
144
|
+
}
|
|
145
|
+
/** Starts or renews the hold; when it runs out the desktop is released as if by hand. */
|
|
146
|
+
hold() {
|
|
147
|
+
this.clearHold();
|
|
148
|
+
this.holdExpiresAt = Date.now() + this.holdMs;
|
|
149
|
+
this.holdTimer = setTimeout(() => {
|
|
150
|
+
this.holdTimer = undefined;
|
|
151
|
+
if (this.holder === 'human')
|
|
152
|
+
this.release();
|
|
153
|
+
}, this.holdMs);
|
|
154
|
+
this.holdTimer.unref?.();
|
|
155
|
+
}
|
|
156
|
+
clearHold() {
|
|
157
|
+
if (this.holdTimer)
|
|
158
|
+
clearTimeout(this.holdTimer);
|
|
159
|
+
this.holdTimer = undefined;
|
|
160
|
+
this.holdExpiresAt = undefined;
|
|
120
161
|
}
|
|
121
162
|
currentStatus() {
|
|
122
163
|
if (this.source)
|
|
@@ -248,6 +289,7 @@ export function desktopRoute(pathname) {
|
|
|
248
289
|
return (pathname === '/runtime/desktop/health' ||
|
|
249
290
|
pathname === '/runtime/desktop/connect' ||
|
|
250
291
|
pathname === '/runtime/desktop/takeover' ||
|
|
292
|
+
pathname === '/runtime/desktop/renew' ||
|
|
251
293
|
pathname === '/runtime/desktop' ||
|
|
252
294
|
pathname.startsWith('/runtime/desktop/resources/'));
|
|
253
295
|
}
|
|
@@ -268,6 +310,10 @@ function loopbackUpstream(value) {
|
|
|
268
310
|
return undefined;
|
|
269
311
|
return { port };
|
|
270
312
|
}
|
|
313
|
+
function holdDuration(value) {
|
|
314
|
+
const parsed = Number(value);
|
|
315
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : HOLD_MS;
|
|
316
|
+
}
|
|
271
317
|
function probePort(port) {
|
|
272
318
|
return new Promise((resolve) => {
|
|
273
319
|
const socket = connectTcp({ host: '127.0.0.1', port });
|
|
@@ -596,7 +596,7 @@ async function executeGitCommit(state, scopeKey, operation, payload) {
|
|
|
596
596
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_git_commit' }));
|
|
597
597
|
}
|
|
598
598
|
try {
|
|
599
|
-
const sha = await commitWorkspaceGit(await workspaceRoot(state, operation.workspaceId), parsed.data);
|
|
599
|
+
const sha = await commitWorkspaceGit(await workspaceRoot(state, operation.workspaceId), parsed.data, boundBranch(state, operation));
|
|
600
600
|
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
|
|
601
601
|
await recordNativeEvents(state, accepted, [
|
|
602
602
|
{
|
|
@@ -617,7 +617,7 @@ async function executeGitPush(state, scopeKey, operation, payload) {
|
|
|
617
617
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'remote_not_configurable' }));
|
|
618
618
|
}
|
|
619
619
|
try {
|
|
620
|
-
await pushWorkspaceGit(await workspaceRoot(state, operation.workspaceId));
|
|
620
|
+
await pushWorkspaceGit(await workspaceRoot(state, operation.workspaceId), boundBranch(state, operation));
|
|
621
621
|
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
|
|
622
622
|
await recordNativeEvents(state, accepted, [
|
|
623
623
|
{
|
|
@@ -765,6 +765,10 @@ async function executeTerminalCreate(state, scopeKey, operation, payload) {
|
|
|
765
765
|
]);
|
|
766
766
|
return persist(state, scopeKey, transitionOperation(accepted, 'completed', { terminalId, cwd: record.cwd }));
|
|
767
767
|
}
|
|
768
|
+
/** The branch a registered workspace is bound to; the single-workspace mode has none. */
|
|
769
|
+
function boundBranch(state, operation) {
|
|
770
|
+
return operation.workspaceId ? state.journal.getWorkspace(operation.workspaceId)?.branchName : undefined;
|
|
771
|
+
}
|
|
768
772
|
function gitErrorCode(error) {
|
|
769
773
|
return error instanceof GitWorkspaceError ? error.code : 'git_command_failed';
|
|
770
774
|
}
|
|
@@ -171,8 +171,34 @@ function splitLogLine(line) {
|
|
|
171
171
|
return [line.slice(0, first), line.slice(first + 1), ''];
|
|
172
172
|
return [line.slice(0, first), line.slice(first + 1, second), line.slice(second + 1)];
|
|
173
173
|
}
|
|
174
|
-
|
|
174
|
+
/**
|
|
175
|
+
* Commit and push stay on the branch the workspace is bound to. A switch made in a shell or by an
|
|
176
|
+
* agent is reported, never followed, so work cannot land on another Remix.
|
|
177
|
+
*/
|
|
178
|
+
async function assertBoundBranch(rootReal, boundBranch) {
|
|
179
|
+
if (!boundBranch)
|
|
180
|
+
return;
|
|
181
|
+
const current = await gitAllowFail(rootReal, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
|
|
182
|
+
const branch = current.code === 0 ? current.stdout.trim() : '';
|
|
183
|
+
if (branch !== boundBranch) {
|
|
184
|
+
throw new GitWorkspaceError('workspace_branch_mismatch', branch || 'detached HEAD');
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* A push the remote refused for this ref is a definite outcome, unlike a lost response. Porcelain
|
|
189
|
+
* output marks the ref with "!" and says whether the remote has commits this checkout lacks.
|
|
190
|
+
*/
|
|
191
|
+
function pushRejection(stdout) {
|
|
192
|
+
const line = stdout.split('\n').find((item) => item.startsWith('!\t'));
|
|
193
|
+
if (!line)
|
|
194
|
+
return undefined;
|
|
195
|
+
if (/\[rejected\] \((?:non-fast-forward|fetch first)\)/.test(line))
|
|
196
|
+
return 'git_push_remote_ahead';
|
|
197
|
+
return 'git_push_rejected';
|
|
198
|
+
}
|
|
199
|
+
export async function commitWorkspaceGit(root, payload, boundBranch) {
|
|
175
200
|
const rootReal = await ensureWorkspaceGit(root);
|
|
201
|
+
await assertBoundBranch(rootReal, boundBranch);
|
|
176
202
|
if (payload.stagedOnly) {
|
|
177
203
|
// The index is the commit; nothing is added.
|
|
178
204
|
}
|
|
@@ -190,8 +216,9 @@ export async function commitWorkspaceGit(root, payload) {
|
|
|
190
216
|
await git(rootReal, ['commit', '-m', payload.message], 20000);
|
|
191
217
|
return (await git(rootReal, ['rev-parse', 'HEAD'])).trim();
|
|
192
218
|
}
|
|
193
|
-
export async function pushWorkspaceGit(root) {
|
|
219
|
+
export async function pushWorkspaceGit(root, boundBranch) {
|
|
194
220
|
const rootReal = await ensureWorkspaceGit(root);
|
|
221
|
+
await assertBoundBranch(rootReal, boundBranch);
|
|
195
222
|
const origin = await gitAllowFail(rootReal, ['remote', 'get-url', 'origin']);
|
|
196
223
|
if (origin.code !== 0) {
|
|
197
224
|
throw new GitWorkspaceError('remote_not_configured', 'origin');
|
|
@@ -200,12 +227,16 @@ export async function pushWorkspaceGit(root) {
|
|
|
200
227
|
if (!ref.startsWith('refs/heads/'))
|
|
201
228
|
throw new GitWorkspaceError('git_command_failed', 'git_branch_required');
|
|
202
229
|
const commit = (await git(rootReal, ['rev-parse', '--verify', 'HEAD^{commit}'])).trim();
|
|
230
|
+
let pushed;
|
|
203
231
|
try {
|
|
204
|
-
|
|
205
|
-
if (pushed.code === 0)
|
|
206
|
-
return;
|
|
232
|
+
pushed = await gitAllowFail(rootReal, ['push', '--porcelain', 'origin', `${commit}:${ref}`], 90000);
|
|
207
233
|
}
|
|
208
234
|
catch { /* A lost response does not prove the remote rejected the update. */ }
|
|
235
|
+
if (pushed?.code === 0)
|
|
236
|
+
return;
|
|
237
|
+
const rejected = pushed ? pushRejection(pushed.stdout) : undefined;
|
|
238
|
+
if (rejected)
|
|
239
|
+
throw new GitWorkspaceError(rejected, rejected);
|
|
209
240
|
try {
|
|
210
241
|
const remote = await gitAllowFail(rootReal, ['ls-remote', '--refs', 'origin', ref], 20000);
|
|
211
242
|
if (remote.code === 0 && remote.stdout.trim() === `${commit}\t${ref}`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
"@gltf-transform/core": "^4.3.0",
|
|
49
49
|
"@gltf-transform/extensions": "^4.3.0",
|
|
50
50
|
"@gltf-transform/functions": "^4.3.0",
|
|
51
|
-
"@playcraft/build": "^0.1.
|
|
52
|
-
"@playcraft/common": "^0.1.
|
|
51
|
+
"@playcraft/build": "^0.1.8",
|
|
52
|
+
"@playcraft/common": "^0.1.8",
|
|
53
53
|
"better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
|
|
54
54
|
"chokidar": "^4.0.3",
|
|
55
55
|
"commander": "^13.1.0",
|