@playcraft/cli 0.1.0 → 0.1.2

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,9 +1,8 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { randomUUID, timingSafeEqual } from 'node:crypto';
3
- import { constants } from 'node:fs';
4
- import { lstat, mkdir, open, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
5
- import { isAbsolute, join, resolve } from 'node:path';
6
- import { matchWorkspaceGitGatewayCloneUrl, workspaceGitAccessSchema, workspaceGitTransportSchema, workspacePreparationSchema, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { isLocalWorkspaceGitAuthMode, isLocalWorkspaceGitEnvironment, matchWorkspaceGitGatewayCloneUrl, workspacePreparationSchema, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
7
6
  import { WorkspaceContextError } from './context-error.js';
8
7
  /** The caller holds the Workspace lock. Only an absent, never-published checkout may be cloned. */
9
8
  export async function prepareWorkspaceCheckout(state, binding, input, leaseFence) {
@@ -11,6 +10,10 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
11
10
  if (!preparation.success)
12
11
  throw new WorkspaceContextError('invalid_workspace_preparation');
13
12
  const source = parseCloneSource(binding.repositoryUrl);
13
+ const direct = isLocalWorkspaceGitAuthMode(preparation.data.authMode);
14
+ if (direct && (!isLocalWorkspaceGitEnvironment(state.config.environment) || !preparation.data.directAccess
15
+ || preparation.data.directAccess.repositoryUrl !== binding.repositoryUrl))
16
+ throw new WorkspaceContextError('workspace_git_gateway_unavailable');
14
17
  const parent = await realpath(state.config.workspaceRoot);
15
18
  const target = join(parent, binding.workspaceId);
16
19
  const receiptDir = join(state.config.stateDir, 'workspace-preparations');
@@ -40,19 +43,17 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
40
43
  await rm(staging, { recursive: true, force: true });
41
44
  await mkdir(staging, { mode: 0o700 });
42
45
  const access = preparation.data.gitAccess;
46
+ const credential = preparation.data.directAccess ?? access;
43
47
  const now = Date.now();
44
48
  if (access &&
45
- (Date.parse(access.issuedAt) > now || Date.parse(access.expiresAt) <= now + 10_000))
49
+ (Date.parse(access.issuedAt) > now || Date.parse(access.expiresAt) <= now + 10_000)) {
50
+ await rm(staging, { recursive: true, force: true });
46
51
  throw new WorkspaceContextError('workspace_git_access_expired');
47
- let gateway;
48
- try {
49
- gateway = access
50
- ? await mountGateway(state, binding.repositoryUrl, access, preparation.data.transport)
51
- : undefined;
52
52
  }
53
- catch (error) {
53
+ const transport = preparation.data.transport;
54
+ if (transport && !matchWorkspaceGitGatewayCloneUrl(transport.cloneUrl, state.config.gitGatewayOrigins, binding.repositoryUrl).ok) {
54
55
  await rm(staging, { recursive: true, force: true });
55
- throw error;
56
+ throw new WorkspaceContextError('workspace_git_gateway_unavailable');
56
57
  }
57
58
  const deadline = Math.min(now + 90_000, access ? Date.parse(access.expiresAt) : Infinity);
58
59
  const env = {
@@ -63,23 +64,16 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
63
64
  GIT_TERMINAL_PROMPT: '0',
64
65
  GIT_ALLOW_PROTOCOL: 'http:https',
65
66
  };
66
- const cloneConfig = [];
67
- if (gateway) {
68
- // Rewrite only inside this process. The password is read from the mounted directory for the proxy URL.
69
- env.GIT_CONFIG_COUNT = '1';
70
- env.GIT_CONFIG_KEY_0 = `url.${gateway.cloneUrl}.insteadOf`;
71
- env.GIT_CONFIG_VALUE_0 = binding.repositoryUrl;
72
- env.PLAYCRAFT_GIT_HELPER = Buffer.from(GIT_GATEWAY_CREDENTIAL_HELPER).toString('base64');
73
- env.PLAYCRAFT_GIT_CREDENTIAL_DIRECTORY = gateway.directory;
74
- env.PLAYCRAFT_GIT_GATEWAY_CLONE_URL = gateway.cloneUrl;
75
- env.PLAYCRAFT_GIT_CANONICAL_URL = binding.repositoryUrl;
76
- cloneConfig.push(`credential.helper=!${shellQuote(process.execPath)} -e ${shellQuote('eval(Buffer.from(process.env.PLAYCRAFT_GIT_HELPER,"base64").toString())')}`, 'credential.useHttpPath=true');
77
- }
78
- else if (access) {
79
- // Authentication exists only in the clone process environment; never in arguments or .git/config.
80
- env.GIT_CONFIG_COUNT = '1';
81
- env.GIT_CONFIG_KEY_0 = `http.${source.href}.extraHeader`;
82
- env.GIT_CONFIG_VALUE_0 = `Authorization: Basic ${Buffer.from(`${access.username}:${access.password}`).toString('base64')}`;
67
+ // The authenticated registration already carries this operation's scoped credential.
68
+ // No publisher ledger is mounted in the sandbox; secrets exist only in this child environment.
69
+ if (credential) {
70
+ env.GIT_CONFIG_COUNT = transport ? '2' : '1';
71
+ env.GIT_CONFIG_KEY_0 = `http.${transport?.cloneUrl ?? source.href}.extraHeader`;
72
+ env.GIT_CONFIG_VALUE_0 = `Authorization: Basic ${Buffer.from(`${credential.username}:${credential.password}`).toString('base64')}`;
73
+ if (transport) {
74
+ env.GIT_CONFIG_KEY_1 = `url.${transport.cloneUrl}.insteadOf`;
75
+ env.GIT_CONFIG_VALUE_1 = binding.repositoryUrl;
76
+ }
83
77
  }
84
78
  try {
85
79
  await runGit(['check-ref-format', '--branch', binding.branchName], env, deadline);
@@ -92,7 +86,7 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
92
86
  '--',
93
87
  binding.repositoryUrl,
94
88
  staging,
95
- ], env, deadline, cloneConfig);
89
+ ], env, deadline);
96
90
  // Checkout never receives the temporary credential and never invokes repository hooks or global filters.
97
91
  clearCloneSecrets(env);
98
92
  await runGit(['-C', staging, 'checkout', '--force', binding.branchName], env, deadline);
@@ -106,6 +100,9 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
106
100
  throw error;
107
101
  throw new WorkspaceContextError('workspace_clone_failed');
108
102
  }
103
+ finally {
104
+ clearCloneSecrets(env);
105
+ }
109
106
  }
110
107
  const stat = await lstat(staging).catch(() => undefined);
111
108
  if (!stat?.isDirectory() || stat.isSymbolicLink())
@@ -124,149 +121,10 @@ function clearCloneSecrets(env) {
124
121
  'GIT_CONFIG_VALUE_0',
125
122
  'GIT_CONFIG_KEY_1',
126
123
  'GIT_CONFIG_VALUE_1',
127
- 'PLAYCRAFT_GIT_HELPER',
128
- 'PLAYCRAFT_GIT_CREDENTIAL_DIRECTORY',
129
- 'PLAYCRAFT_GIT_GATEWAY_CLONE_URL',
130
- 'PLAYCRAFT_GIT_CANONICAL_URL',
131
124
  ]) {
132
125
  delete env[key];
133
126
  }
134
127
  }
135
- function shellQuote(value) {
136
- return `'${value.replaceAll("'", "'\\''")}'`;
137
- }
138
- /** Read-only open of the publisher's control directory. Credentials are not copied into the checkout. */
139
- async function mountGateway(state, repositoryUrl, access, transport) {
140
- if (!transport)
141
- return undefined;
142
- const directory = state.config.gitCredentialDirectory;
143
- const matched = matchWorkspaceGitGatewayCloneUrl(transport.cloneUrl, state.config.gitGatewayOrigins, repositoryUrl);
144
- if (!matched.ok || !directory)
145
- throw new WorkspaceContextError('workspace_git_gateway_unavailable');
146
- const parsed = workspaceGitTransportSchema.parse(transport);
147
- if (parsed.cloneUrl !== transport.cloneUrl) {
148
- throw new WorkspaceContextError('workspace_git_gateway_unavailable');
149
- }
150
- const entry = await readMountedEntry(directory, transport.cloneUrl, matched.requestId, repositoryUrl);
151
- if (entry.username !== access.username ||
152
- entry.issuedAt !== access.issuedAt ||
153
- entry.expiresAt !== access.expiresAt ||
154
- !sameSecret(entry.password, access.password)) {
155
- throw new WorkspaceContextError('workspace_git_gateway_unavailable');
156
- }
157
- return { directory, cloneUrl: transport.cloneUrl };
158
- }
159
- async function readMountedEntry(directory, cloneUrl, requestId, repositoryUrl) {
160
- if (!isAbsolute(directory) || (await realpath(directory)) !== resolve(directory)) {
161
- throw new WorkspaceContextError('workspace_git_gateway_unavailable');
162
- }
163
- const directoryStat = await lstat(directory);
164
- if (!directoryStat.isDirectory() ||
165
- directoryStat.isSymbolicLink() ||
166
- (directoryStat.mode & 0o077) !== 0 ||
167
- (typeof process.getuid === 'function' && directoryStat.uid !== process.getuid())) {
168
- throw new WorkspaceContextError('workspace_git_gateway_unavailable');
169
- }
170
- const directoryHandle = await open(directory, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_DIRECTORY);
171
- await directoryHandle.close();
172
- const file = await open(join(directory, 'workspace-git-credentials.json'), constants.O_RDONLY | constants.O_NOFOLLOW);
173
- try {
174
- const stat = await file.stat();
175
- if (!stat.isFile() || (stat.mode & 0o077) !== 0 || stat.size > 1024 * 1024) {
176
- throw new WorkspaceContextError('workspace_git_gateway_unavailable');
177
- }
178
- const document = JSON.parse(await file.readFile('utf8'));
179
- if (blocked(document).has(requestId)) {
180
- throw new WorkspaceContextError('workspace_git_gateway_unavailable');
181
- }
182
- const entries = (document.entries ?? []).filter((entry) => entry.transport &&
183
- typeof entry.transport === 'object' &&
184
- entry.transport.cloneUrl === cloneUrl);
185
- if (entries.length !== 1)
186
- throw new WorkspaceContextError('workspace_git_gateway_unavailable');
187
- const entry = entries[0];
188
- if (entry.repositoryUrl !== repositoryUrl ||
189
- requestIdOf(entry.command) !== requestId ||
190
- requestIdOf(entry.receipt) !== requestId) {
191
- throw new WorkspaceContextError('workspace_git_gateway_unavailable');
192
- }
193
- return workspaceGitAccessSchema.parse(entry.gitAccess);
194
- }
195
- finally {
196
- await file.close();
197
- }
198
- }
199
- function requestIdOf(value) {
200
- if (!value || typeof value !== 'object')
201
- return undefined;
202
- const id = value.requestId;
203
- return typeof id === 'string' ? id : undefined;
204
- }
205
- function blocked(document) {
206
- const ids = new Set();
207
- for (const list of [document.retired, document.revocations]) {
208
- if (list === undefined)
209
- continue;
210
- if (!Array.isArray(list))
211
- throw new WorkspaceContextError('workspace_git_gateway_unavailable');
212
- for (const item of list) {
213
- const id = requestIdOf(item);
214
- if (id)
215
- ids.add(id);
216
- }
217
- }
218
- return ids;
219
- }
220
- function sameSecret(left, right) {
221
- const a = Buffer.from(left);
222
- const b = Buffer.from(right);
223
- return a.length === b.length && timingSafeEqual(a, b);
224
- }
225
- const GIT_GATEWAY_CREDENTIAL_HELPER = `
226
- const fs = require('node:fs');
227
- const path = require('node:path');
228
- const action = process.argv[1];
229
- if (action !== 'get') process.exit(0);
230
- const chunks = [];
231
- process.stdin.on('data', (chunk) => chunks.push(chunk));
232
- process.stdin.on('end', () => {
233
- try {
234
- const fields = {};
235
- for (const line of Buffer.concat(chunks).toString('utf8').split('\\n')) {
236
- if (!line) continue;
237
- const index = line.indexOf('=');
238
- if (index > 0) fields[line.slice(0, index)] = line.slice(index + 1);
239
- }
240
- const cloneUrl = new URL(process.env.PLAYCRAFT_GIT_GATEWAY_CLONE_URL);
241
- const canonical = new URL(process.env.PLAYCRAFT_GIT_CANONICAL_URL);
242
- if (!fields.path || !fields.protocol || !fields.host) process.exit(1);
243
- const requested = new URL(fields.protocol + '://' + fields.host + '/' + String(fields.path).replace(/^\\/+/, ''));
244
- if (requested.origin !== cloneUrl.origin || requested.pathname !== cloneUrl.pathname || requested.origin === canonical.origin) process.exit(1);
245
- const requestId = cloneUrl.pathname.split('/').pop();
246
- const directory = process.env.PLAYCRAFT_GIT_CREDENTIAL_DIRECTORY;
247
- const flags = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW;
248
- const dir = fs.openSync(directory, flags | (fs.constants.O_DIRECTORY || 0));
249
- try {
250
- if (!fs.fstatSync(dir).isDirectory()) process.exit(1);
251
- } finally { fs.closeSync(dir); }
252
- const file = fs.openSync(path.join(directory, 'workspace-git-credentials.json'), flags);
253
- try {
254
- const stat = fs.fstatSync(file);
255
- if (!stat.isFile() || (stat.mode & 0o077) !== 0 || stat.size > 1024 * 1024) process.exit(1);
256
- const document = JSON.parse(fs.readFileSync(file, 'utf8'));
257
- const blocked = [...(document.retired || []), ...(document.revocations || [])];
258
- if (blocked.some((item) => item && item.requestId === requestId)) process.exit(1);
259
- const entries = (document.entries || []).filter((entry) => entry && entry.transport && entry.transport.cloneUrl === process.env.PLAYCRAFT_GIT_GATEWAY_CLONE_URL && entry.repositoryUrl === process.env.PLAYCRAFT_GIT_CANONICAL_URL);
260
- if (entries.length !== 1) process.exit(1);
261
- const access = entries[0].gitAccess || {};
262
- if (Date.parse(access.issuedAt) > Date.now() || Date.parse(access.expiresAt) <= Date.now() + 10000) process.exit(1);
263
- if (!access.username || !access.password || /[\\r\\n\\0]/.test(access.username) || /[\\r\\n\\0]/.test(access.password)) process.exit(1);
264
- process.stdout.write('username=' + access.username + '\\npassword=' + access.password + '\\n');
265
- process.exit(0);
266
- } finally { fs.closeSync(file); }
267
- } catch { process.exit(1); }
268
- });
269
- `;
270
128
  function parseCloneSource(value) {
271
129
  try {
272
130
  const source = new URL(value);
@@ -316,7 +174,7 @@ async function writeReceipt(path, value) {
316
174
  await writeFile(temp, `${JSON.stringify(value)}\n`, { mode: 0o600 });
317
175
  await rename(temp, path);
318
176
  }
319
- function runGit(args, env, deadline, config = []) {
177
+ function runGit(args, env, deadline) {
320
178
  return new Promise((resolve, reject) => {
321
179
  let timedOut = false;
322
180
  const child = spawn('git', [
@@ -324,7 +182,6 @@ function runGit(args, env, deadline, config = []) {
324
182
  'core.hooksPath=/dev/null',
325
183
  '-c',
326
184
  'credential.helper=',
327
- ...config.flatMap((item) => ['-c', item]),
328
185
  '-c',
329
186
  'http.followRedirects=false',
330
187
  '-c',
@@ -2,7 +2,7 @@ import { execFile } from 'node:child_process';
2
2
  import { lstat, realpath } from 'node:fs/promises';
3
3
  import { join, relative } from 'node:path';
4
4
  import { promisify } from 'node:util';
5
- import { workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { isLocalWorkspaceGitAuthMode, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
6
  import { prepareWorkspaceCheckout } from './prepare.js';
7
7
  import { WorkspaceContextError } from './context-error.js';
8
8
  export { WorkspaceContextError } from './context-error.js';
@@ -87,7 +87,7 @@ export async function registerWorkspace(state, input, leaseFence, preparation) {
87
87
  })).stdout.trim();
88
88
  try {
89
89
  if ((await realpath(await git('rev-parse', '--show-toplevel'))) !== root ||
90
- (await git('remote', 'get-url', 'origin')) !== binding.repositoryUrl ||
90
+ (await git('config', '--get', 'remote.origin.url')) !== binding.repositoryUrl ||
91
91
  (await git('symbolic-ref', '--short', 'HEAD')) !== binding.branchName) {
92
92
  throw new WorkspaceContextError('workspace_checkout_mismatch');
93
93
  }
@@ -101,6 +101,16 @@ export async function registerWorkspace(state, input, leaseFence, preparation) {
101
101
  throw new WorkspaceContextError('runtime_destroyed');
102
102
  if (leaseFence < state.acceptedLeaseFence)
103
103
  throw new WorkspaceContextError('stale_lease');
104
+ if (preparation?.transport?.kind === 'git-gateway-v2' || isLocalWorkspaceGitAuthMode(preparation?.authMode)) {
105
+ if (!state.gitCredentials)
106
+ throw new WorkspaceContextError('workspace_git_gateway_unavailable');
107
+ try {
108
+ await state.gitCredentials.configure(binding, preparation);
109
+ }
110
+ catch {
111
+ throw new WorkspaceContextError('workspace_git_gateway_unavailable');
112
+ }
113
+ }
104
114
  await state.journal.putWorkspace(binding);
105
115
  return binding;
106
116
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -24,7 +24,7 @@
24
24
  "dev": "tsc -w",
25
25
  "build": "tsc",
26
26
  "start": "node dist/index.js",
27
- "test": "vitest run",
27
+ "test": "pnpm build && vitest run",
28
28
  "test:agent": "node scripts/run-agent-tests.mjs --list",
29
29
  "test:agent:designer": "node scripts/run-agent-tests.mjs game-designer",
30
30
  "test:agent:developer": "node scripts/run-agent-tests.mjs developer",
@@ -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.0",
52
- "@playcraft/common": "^0.1.0",
51
+ "@playcraft/build": "^0.1.2",
52
+ "@playcraft/common": "^0.1.2",
53
53
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
54
54
  "chokidar": "^4.0.3",
55
55
  "commander": "^13.1.0",