@playcraft/cli 0.1.0 → 0.1.1

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.
@@ -0,0 +1,154 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, mkdir, open, realpath, readdir, rename, unlink } from 'node:fs/promises';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { join, resolve, sep } from 'node:path';
5
+ import { isLocalWorkspaceGitEnvironment, workspaceGitDirectAccessSchema } from '@playcraft/common/cloud-workspace/runtime';
6
+ /** Control-owned files only. Same-user processes are not an operating-system isolation boundary. */
7
+ export class WorkspaceGitDirectStore {
8
+ state;
9
+ mutation = Promise.resolve();
10
+ constructor(state) {
11
+ this.state = state;
12
+ }
13
+ exclusive(action) {
14
+ const result = this.mutation.then(action);
15
+ this.mutation = result.catch(() => undefined);
16
+ return result;
17
+ }
18
+ identity() {
19
+ const c = this.state.config;
20
+ return JSON.stringify([c.environment, c.userId, c.runtimeId, c.generation]);
21
+ }
22
+ name(id) {
23
+ if (!/^[a-f0-9-]{36}$/.test(id))
24
+ throw new Error('git_access_denied');
25
+ return `${id}.json`;
26
+ }
27
+ async directory(create, action) {
28
+ const stateDir = resolve(this.state.config.stateDir);
29
+ const directory = join(stateDir, 'direct-git');
30
+ if (!create && !(await lstat(directory).catch(error => {
31
+ if (error.code === 'ENOENT')
32
+ return undefined;
33
+ throw error;
34
+ })))
35
+ return undefined;
36
+ const root = await realpath(this.state.config.workspaceRoot);
37
+ if (this.state.config.stateDir !== stateDir || await realpath(stateDir) !== stateDir || stateDir === root || stateDir.startsWith(`${root}${sep}`)) {
38
+ throw new Error('git_control_directory_rejected');
39
+ }
40
+ for (const path of [stateDir, directory]) {
41
+ if (path === directory && create)
42
+ await mkdir(path, { mode: 0o700 }).catch(error => {
43
+ if (error.code !== 'EEXIST')
44
+ throw error;
45
+ });
46
+ const stat = await lstat(path);
47
+ if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0) {
48
+ throw new Error('git_control_directory_rejected');
49
+ }
50
+ }
51
+ const handle = await open(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
52
+ try {
53
+ const pinned = process.platform === 'linux' ? `/proc/self/fd/${handle.fd}` : directory;
54
+ return await action(pinned);
55
+ }
56
+ finally {
57
+ await handle.close();
58
+ }
59
+ }
60
+ async read(path) {
61
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW).catch(error => {
62
+ if (error.code === 'ENOENT')
63
+ return undefined;
64
+ throw error;
65
+ });
66
+ if (!handle)
67
+ return undefined;
68
+ try {
69
+ const stat = await handle.stat();
70
+ if (!stat.isFile() || stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o600 || stat.nlink !== 1 || stat.size > 16384) {
71
+ throw new Error('git_credential_file_rejected');
72
+ }
73
+ return JSON.parse(await handle.readFile('utf8'));
74
+ }
75
+ finally {
76
+ await handle.close();
77
+ }
78
+ }
79
+ async get(binding) {
80
+ return this.directory(false, async (path) => {
81
+ const stored = await this.read(join(path, this.name(binding.workspaceId)));
82
+ if (!stored)
83
+ return undefined;
84
+ if (stored.identity !== this.identity() || JSON.stringify(stored.binding) !== JSON.stringify(binding)
85
+ || !isLocalWorkspaceGitEnvironment(this.state.config.environment))
86
+ throw new Error('git_access_denied');
87
+ if (stored.access !== null) {
88
+ const access = workspaceGitDirectAccessSchema.parse(stored.access);
89
+ if (access.repositoryUrl !== binding.repositoryUrl)
90
+ throw new Error('git_access_denied');
91
+ }
92
+ return stored;
93
+ });
94
+ }
95
+ async put(binding, access, rejectedPassword) {
96
+ if (!isLocalWorkspaceGitEnvironment(this.state.config.environment) || this.state.journal.destroyed())
97
+ throw new Error('git_access_denied');
98
+ if (access && (workspaceGitDirectAccessSchema.parse(access).repositoryUrl !== binding.repositoryUrl))
99
+ throw new Error('git_access_denied');
100
+ await this.exclusive(() => this.directory(true, async (path) => {
101
+ const target = join(path, this.name(binding.workspaceId));
102
+ // Refuse symlink/hardlink/permissive old files even though rename would replace them.
103
+ const old = await this.read(target);
104
+ if (rejectedPassword !== undefined && old?.access?.password !== rejectedPassword)
105
+ return;
106
+ const temporary = join(path, `${randomUUID()}.tmp`);
107
+ const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
108
+ try {
109
+ await handle.writeFile(JSON.stringify({ identity: this.identity(), binding, access }));
110
+ await handle.sync();
111
+ await handle.close();
112
+ if (this.state.journal.destroyed())
113
+ throw new Error('git_access_denied');
114
+ await rename(temporary, target);
115
+ const dir = await open(path, constants.O_RDONLY | constants.O_DIRECTORY);
116
+ try {
117
+ await dir.sync();
118
+ }
119
+ finally {
120
+ await dir.close();
121
+ }
122
+ }
123
+ finally {
124
+ await handle.close();
125
+ await unlink(temporary).catch(error => { if (error.code !== 'ENOENT')
126
+ throw error; });
127
+ }
128
+ }));
129
+ }
130
+ async remove(workspaceId) {
131
+ await this.exclusive(() => this.directory(false, async (path) => {
132
+ const target = join(path, this.name(workspaceId));
133
+ await this.read(target);
134
+ await unlink(target).catch(error => { if (error.code !== 'ENOENT')
135
+ throw error; });
136
+ }));
137
+ }
138
+ async sweep(destroy = false) {
139
+ await this.exclusive(() => this.directory(false, async (path) => {
140
+ for (const name of await readdir(path)) {
141
+ if (!/^[a-f0-9-]{36}\.(json|tmp)$/.test(name))
142
+ throw new Error('git_credential_file_rejected');
143
+ const target = join(path, name);
144
+ if (name.endsWith('.tmp')) {
145
+ await unlink(target);
146
+ continue;
147
+ }
148
+ const stored = await this.read(target);
149
+ if (destroy || stored?.identity !== this.identity() || !isLocalWorkspaceGitEnvironment(this.state.config.environment))
150
+ await unlink(target);
151
+ }
152
+ }));
153
+ }
154
+ }
@@ -116,10 +116,23 @@ export async function pushWorkspaceGit(root) {
116
116
  if (origin.code !== 0) {
117
117
  throw new GitWorkspaceError('remote_not_configured', 'origin');
118
118
  }
119
- const pushed = await gitAllowFail(rootReal, ['push', 'origin', 'HEAD'], 20000);
120
- if (pushed.code !== 0) {
121
- throw new GitWorkspaceError('git_command_failed', pushed.stderr.trim() || 'git_push_failed');
119
+ const ref = (await git(rootReal, ['symbolic-ref', '--quiet', 'HEAD'])).trim();
120
+ if (!ref.startsWith('refs/heads/'))
121
+ throw new GitWorkspaceError('git_command_failed', 'git_branch_required');
122
+ const commit = (await git(rootReal, ['rev-parse', '--verify', 'HEAD^{commit}'])).trim();
123
+ try {
124
+ const pushed = await gitAllowFail(rootReal, ['push', '--porcelain', 'origin', `${commit}:${ref}`], 90000);
125
+ if (pushed.code === 0)
126
+ return;
127
+ }
128
+ catch { /* A lost response does not prove the remote rejected the update. */ }
129
+ try {
130
+ const remote = await gitAllowFail(rootReal, ['ls-remote', '--refs', 'origin', ref], 20000);
131
+ if (remote.code === 0 && remote.stdout.trim() === `${commit}\t${ref}`)
132
+ return;
122
133
  }
134
+ catch { /* Preserve uncertainty; never replay a write automatically. */ }
135
+ throw new GitWorkspaceError('git_push_outcome_unknown', 'git_push_outcome_unknown');
123
136
  }
124
137
  export async function ensureWorkspaceGit(root) {
125
138
  const rootReal = await realpath(root);
@@ -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.1",
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.1",
52
+ "@playcraft/common": "^0.1.1",
53
53
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
54
54
  "chokidar": "^4.0.3",
55
55
  "commander": "^13.1.0",