@playcraft/cli 0.1.26 → 0.1.27

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.
@@ -292,6 +292,42 @@ export async function saveWorkspaceGit(root, input) {
292
292
  await recordPushedHead(rootReal);
293
293
  return { ...(commit ? { commit } : {}), pushed: true, withheld };
294
294
  }
295
+ /**
296
+ * Connects a standalone checkout to the repository it was just given, keeping all its history and
297
+ * uncommitted work. The new repository holds one empty commit on `branch`; it becomes the base of an
298
+ * unborn checkout, or is merged into existing history, so the next push is a plain fast-forward.
299
+ * Credentials come from the workspace's configured helper and never enter the remote URL.
300
+ * Safe to run again after a failure part way.
301
+ */
302
+ export async function attachWorkspaceRemote(root, repositoryUrl, branch) {
303
+ const rootReal = await ensureWorkspaceGit(root);
304
+ const origin = await gitAllowFail(rootReal, ['remote', 'get-url', 'origin']);
305
+ if (origin.code !== 0)
306
+ await git(rootReal, ['remote', 'add', 'origin', repositoryUrl]);
307
+ else if (origin.stdout.trim() !== repositoryUrl)
308
+ throw new GitWorkspaceError('remote_not_configured', 'origin_mismatch');
309
+ const remoteRef = `refs/remotes/origin/${branch}`;
310
+ const fetched = await gitAllowFail(rootReal, ['fetch', '--no-tags', 'origin', `+refs/heads/${branch}:${remoteRef}`], 90000);
311
+ if (fetched.code !== 0)
312
+ throw new GitWorkspaceError(httpRefusal(fetched.stderr) ?? 'git_command_failed', 'git_fetch_failed');
313
+ const current = (await gitAllowFail(rootReal, ['symbolic-ref', '--quiet', '--short', 'HEAD'])).stdout.trim();
314
+ const born = (await gitAllowFail(rootReal, ['rev-parse', '-q', '--verify', 'HEAD^{commit}'])).code === 0;
315
+ if (current !== branch) {
316
+ await git(rootReal, born ? ['branch', '-M', branch] : ['symbolic-ref', 'HEAD', `refs/heads/${branch}`]);
317
+ }
318
+ if (!born) {
319
+ // Nothing committed yet: start the branch at the remote commit; the files stay as changes.
320
+ await git(rootReal, ['update-ref', `refs/heads/${branch}`, remoteRef]);
321
+ }
322
+ else if ((await gitAllowFail(rootReal, ['merge-base', '--is-ancestor', remoteRef, 'HEAD'])).code !== 0) {
323
+ const merged = await gitAllowFail(rootReal, [...(await commitIdentity(rootReal)), 'merge', '--allow-unrelated-histories', '--no-edit', remoteRef], 60000);
324
+ if (merged.code !== 0) {
325
+ await gitAllowFail(rootReal, ['merge', '--abort']);
326
+ throw new GitWorkspaceError('git_command_failed', merged.stderr.trim() || 'git_merge_failed');
327
+ }
328
+ }
329
+ await git(rootReal, ['branch', `--set-upstream-to=origin/${branch}`, branch]);
330
+ }
295
331
  async function unstage(rootReal, paths) {
296
332
  if (paths.length === 0)
297
333
  return;
@@ -1,5 +1,5 @@
1
1
  import { execFile } from 'node:child_process';
2
- import { lstat, mkdir, realpath } from 'node:fs/promises';
2
+ import { lstat, mkdir, realpath, rm, writeFile } from 'node:fs/promises';
3
3
  import { join, relative } from 'node:path';
4
4
  import { promisify } from 'node:util';
5
5
  import { isLocalWorkspaceGitAuthMode, sameWorkspaceRegistration, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
@@ -9,6 +9,7 @@ import { scheduleDependencyInstall } from './dependencies.js';
9
9
  import { ensureWorkspaceSkills, readWorkspaceSkills } from './skills.js';
10
10
  import { loadWorkspaceSkillArtifact, workspacePlatformSkillAccess } from './skill-artifact-loader.js';
11
11
  import { writeWorkspaceCliContext } from './cli-context.js';
12
+ import { attachWorkspaceRemote } from './git.js';
12
13
  export { WorkspaceContextError } from './context-error.js';
13
14
  const exec = promisify(execFile);
14
15
  export function revisionScope(state, workspaceId, directoryName = 'basic') {
@@ -106,8 +107,11 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
106
107
  const existing = state.journal.getWorkspace(binding.workspaceId);
107
108
  const preservedLocalBasic = binding.targetKind !== 'standalone' && binding.preserveLocalBasic === true;
108
109
  const localUpgrade = preservedLocalBasic && binding.targetKind === 'main' && existing?.targetKind === 'standalone';
109
- if (existing && !sameWorkspaceRegistration(existing, binding) && !localUpgrade)
110
+ // A standalone workspace promoted to a project's main keeps its checkout and gains that remote.
111
+ const remoteUpgrade = !preservedLocalBasic && binding.targetKind === 'main' && existing?.targetKind === 'standalone';
112
+ if (existing && !sameWorkspaceRegistration(existing, binding) && !localUpgrade && !remoteUpgrade)
110
113
  throw new WorkspaceContextError('workspace_binding_conflict');
114
+ const attachMarker = join(state.config.stateDir, 'workspace-remote-attach', `${binding.workspaceId}.json`);
111
115
  const directories = binding.directories ?? existing?.directories;
112
116
  if (directories?.some(directory => directory.directoryName === 'basic' && directory.status === 'reclaimed')) {
113
117
  // Registration publishes trusted identity. Only an explicit basic ensure restores the checkout.
@@ -142,6 +146,28 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
142
146
  GIT_CONFIG_GLOBAL: '/dev/null',
143
147
  },
144
148
  })).stdout.trim();
149
+ // Unfinished if it failed part way: the marker outlives the journal entry written below.
150
+ if (binding.targetKind === 'main' && !preservedLocalBasic && (remoteUpgrade || await lstat(attachMarker).then(() => true, () => false))) {
151
+ if (preparation?.mode !== 'clone' || !state.gitCredentials)
152
+ throw new WorkspaceContextError('workspace_git_gateway_unavailable');
153
+ await mkdir(join(state.config.stateDir, 'workspace-remote-attach'), { recursive: true, mode: 0o700 });
154
+ await writeFile(attachMarker, JSON.stringify({ repositoryUrl: binding.repositoryUrl, branchName: binding.branchName }), { mode: 0o600 });
155
+ // The credential helper only serves a workspace the journal knows to be bound.
156
+ await state.journal.putWorkspace({ ...binding, directories });
157
+ try {
158
+ await state.gitCredentials.configure(binding, preparation);
159
+ }
160
+ catch {
161
+ throw new WorkspaceContextError('workspace_git_gateway_unavailable');
162
+ }
163
+ try {
164
+ await attachWorkspaceRemote(root, binding.repositoryUrl, binding.branchName);
165
+ }
166
+ catch {
167
+ throw new WorkspaceContextError('workspace_remote_attach_failed');
168
+ }
169
+ await rm(attachMarker, { force: true });
170
+ }
145
171
  try {
146
172
  let checkoutMatches;
147
173
  if (binding.targetKind === 'standalone' || preservedLocalBasic) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -50,8 +50,8 @@
50
50
  "@gltf-transform/core": "^4.3.0",
51
51
  "@gltf-transform/extensions": "^4.3.0",
52
52
  "@gltf-transform/functions": "^4.3.0",
53
- "@playcraft/build": "^0.1.26",
54
- "@playcraft/common": "^0.1.26",
53
+ "@playcraft/build": "^0.1.27",
54
+ "@playcraft/common": "^0.1.27",
55
55
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
56
56
  "chokidar": "^4.0.3",
57
57
  "commander": "^13.1.0",