@quonfig/cli 0.0.15 → 0.0.17

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.
Files changed (46) hide show
  1. package/README.md +85 -34
  2. package/dist/commands/init.d.ts +1 -0
  3. package/dist/commands/init.js +18 -0
  4. package/dist/commands/migrate.js +48 -2
  5. package/dist/commands/pull.d.ts +14 -1
  6. package/dist/commands/pull.js +64 -4
  7. package/dist/commands/push.d.ts +53 -0
  8. package/dist/commands/push.js +370 -0
  9. package/dist/commands/workspace/bootstrap.d.ts +7 -0
  10. package/dist/commands/workspace/bootstrap.js +58 -2
  11. package/dist/migrate/local-write.d.ts +8 -2
  12. package/dist/migrate/local-write.js +55 -7
  13. package/dist/migrate/migration-report.d.ts +18 -0
  14. package/dist/migrate/migration-report.js +64 -1
  15. package/dist/migrate/push-to-cloud.d.ts +14 -3
  16. package/dist/migrate/push-to-cloud.js +77 -6
  17. package/dist/migrate/source.d.ts +50 -1
  18. package/dist/migrate/sources/launch/translate.d.ts +27 -1
  19. package/dist/migrate/sources/launch/translate.js +123 -6
  20. package/dist/migrate/sources/launch/types.d.ts +3 -0
  21. package/dist/migrate/sources/launch/zod-to-json-schema.d.ts +9 -0
  22. package/dist/migrate/sources/launch/zod-to-json-schema.js +521 -0
  23. package/dist/migrate/sources/launch.js +52 -7
  24. package/dist/push/bare-path-diff.d.ts +40 -0
  25. package/dist/push/bare-path-diff.js +126 -0
  26. package/dist/push/confirm.d.ts +10 -0
  27. package/dist/push/confirm.js +108 -0
  28. package/dist/push/diff-summary.d.ts +56 -0
  29. package/dist/push/diff-summary.js +115 -0
  30. package/dist/push/identity-check.d.ts +53 -0
  31. package/dist/push/identity-check.js +135 -0
  32. package/dist/push/run-push.d.ts +107 -0
  33. package/dist/push/run-push.js +318 -0
  34. package/dist/util/get-client.js +5 -4
  35. package/dist/util/get-valid-token.d.ts +3 -1
  36. package/dist/util/get-valid-token.js +28 -3
  37. package/dist/util/gitea-api.d.ts +15 -1
  38. package/dist/util/gitea-api.js +1 -0
  39. package/dist/util/gitea-token-storage.d.ts +7 -0
  40. package/dist/util/quonfig-json.d.ts +44 -0
  41. package/dist/util/quonfig-json.js +65 -0
  42. package/dist/verify/validate.js +6 -2
  43. package/oclif.manifest.json +104 -1
  44. package/package.json +3 -3
  45. /package/dist/{migrate/push-strategy.d.ts → util/clone-and-stack-push.d.ts} +0 -0
  46. /package/dist/{migrate/push-strategy.js → util/clone-and-stack-push.js} +0 -0
@@ -0,0 +1,53 @@
1
+ import type { JsonObj } from '../result.js';
2
+ import { BaseCommand } from '../index.js';
3
+ import { GiteaTokenResponse } from '../util/gitea-api.js';
4
+ import type { GiteaTokenMintResult } from '../push/run-push.js';
5
+ import { runPush } from '../push/run-push.js';
6
+ export default class Push extends BaseCommand {
7
+ static description: string;
8
+ static examples: string[];
9
+ static flags: {
10
+ dir: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
11
+ workspace: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
12
+ yes: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
13
+ 'skip-validate': import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
14
+ 'no-pin-write': import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
15
+ message: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
16
+ };
17
+ run(): Promise<JsonObj | void>;
18
+ }
19
+ /**
20
+ * Map a raw `gitea.token` API response into the `GiteaTokenMintResult` shape
21
+ * the push core (`runPush`) consumes. Kept as a pure function so we can unit
22
+ * test the wiring without a network — specifically, to regression-test that
23
+ * `workspaceId` on the result is the UUID the server returned, not the slug.
24
+ *
25
+ * The bug this guards against: before the backend started returning
26
+ * `workspaceId`, this module used `resp.workspaceSlug` as the `workspaceId`
27
+ * field, which caused `checkIdentity` to fail when the user passed
28
+ * `--workspace <UUID>` (requested=UUID, backend.workspaceId=slug → mismatch).
29
+ */
30
+ export declare function giteaResponseToMintResult(resp: GiteaTokenResponse): GiteaTokenMintResult;
31
+ /**
32
+ * Wire the real implementations of every dependency runPush needs. Kept in
33
+ * the command file so the pure core stays free of oclif / git shell imports.
34
+ *
35
+ * Returns a `{deps, cleanup}` pair. The caller MUST call `cleanup()` in a
36
+ * finally block so the bare-path probe clone is removed even on errors.
37
+ */
38
+ export declare function buildRealDeps(cmd: Push): {
39
+ deps: Parameters<typeof runPush>[1];
40
+ cleanup: () => Promise<void>;
41
+ };
42
+ /**
43
+ * Copy the contents of `sourceDir` into `destDir`, skipping `.git/` and any
44
+ * temp push-clone scratch directories. Used as the `applyDelta` callback for
45
+ * bare-path pushes — we mirror local files into a fresh clone so that
46
+ * `cloneAndStackPush`'s `git add --all` + diff will reflect the union of
47
+ * (deletes, modifies, adds) we intend.
48
+ *
49
+ * IMPORTANT: this deletes files in destDir that are not in sourceDir so that
50
+ * deletions make it into the push. Without this the clone-and-stack produces
51
+ * an "adds-only" delta and destructive heuristics would never fire in the UI.
52
+ */
53
+ export declare function copyDirMirror(sourceDir: string, destDir: string): Promise<void>;
@@ -0,0 +1,370 @@
1
+ import { execFile as execFileCb } from 'node:child_process';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import * as util from 'node:util';
5
+ import { Flags } from '@oclif/core';
6
+ import { BaseCommand } from '../index.js';
7
+ import { mintGiteaToken } from '../util/gitea-api.js';
8
+ import { getRemoteUrl, gitFetch, gitSetRemote, isGitRepo, } from '../util/git-ops.js';
9
+ import { PushFatalError, runPush } from '../push/run-push.js';
10
+ import { computeBarePathDiff } from '../push/bare-path-diff.js';
11
+ import { getActiveProfile, loadAuthConfig } from '../util/token-storage.js';
12
+ const execFile = util.promisify(execFileCb);
13
+ export default class Push extends BaseCommand {
14
+ static description = `Push local config changes up to your workspace on Quonfig cloud.
15
+
16
+ Enforces three guards before touching the remote:
17
+ 1. The dir's quonfig.json "workspace" pin must match the backend (if set).
18
+ 2. The dir's git origin must match the backend repo URL (if set).
19
+ 3. A diff summary is shown; destructive changes (10+ deletes, >=25% of
20
+ files, or an unpinned dir) require typing the workspace slug to confirm.
21
+
22
+ qfg push # uses QUONFIG_DIR + profile workspace
23
+ qfg push --dir ./our-config
24
+ qfg push --dir ./our-config --workspace acme-prod
25
+ qfg push --dir ./our-config --yes # skip normal Y/N (never skips typed-slug)
26
+ qfg push --dir ./our-config --skip-validate`;
27
+ static examples = [
28
+ '<%= config.bin %> push --dir ./our-config',
29
+ '<%= config.bin %> push --workspace acme-prod --dir ./our-config',
30
+ '<%= config.bin %> push --dir ./our-config --yes',
31
+ ];
32
+ static flags = {
33
+ dir: Flags.string({
34
+ description: 'Local directory to push (defaults to QUONFIG_DIR env var)',
35
+ }),
36
+ workspace: Flags.string({
37
+ description: 'Workspace slug or UUID (defaults to active profile)',
38
+ }),
39
+ yes: Flags.boolean({
40
+ default: false,
41
+ description: 'Skip the standard Y/N confirm. Never skips typed-slug prompts.',
42
+ }),
43
+ 'skip-validate': Flags.boolean({
44
+ default: false,
45
+ description: 'Skip `qfg validate` preflight',
46
+ }),
47
+ 'no-pin-write': Flags.boolean({
48
+ default: false,
49
+ description: 'Do not offer to write workspace slug into quonfig.json on success',
50
+ }),
51
+ message: Flags.string({
52
+ char: 'm',
53
+ description: 'Commit message for bare-path pushes (ignored on clone-path; push what is already committed)',
54
+ }),
55
+ };
56
+ async run() {
57
+ const { flags } = await this.parse(Push);
58
+ const dir = flags.dir || process.env.QUONFIG_DIR;
59
+ if (!dir) {
60
+ return this.err('No directory specified. Use --dir <path> or set QUONFIG_DIR.');
61
+ }
62
+ const resolvedDir = path.resolve(dir);
63
+ // Resolve requested target: --workspace overrides the active profile.
64
+ let requestedTarget = flags.workspace;
65
+ if (!requestedTarget) {
66
+ const authConfig = await loadAuthConfig();
67
+ if (!authConfig) {
68
+ return this.err('No active profile workspace; pass --workspace or run `qfg login`.');
69
+ }
70
+ const activeProfile = getActiveProfile();
71
+ const profile = authConfig.profiles[activeProfile] || authConfig.profiles[authConfig.defaultProfile || 'default'];
72
+ if (!profile || !profile.workspace) {
73
+ return this.err('No active profile workspace; pass --workspace or run `qfg login`.');
74
+ }
75
+ requestedTarget = profile.workspace;
76
+ }
77
+ const { deps, cleanup } = buildRealDeps(this);
78
+ try {
79
+ const result = await runPush({
80
+ dir: resolvedDir,
81
+ requestedTarget,
82
+ yes: flags.yes,
83
+ skipValidate: flags['skip-validate'],
84
+ noPinWrite: flags['no-pin-write'],
85
+ message: flags.message,
86
+ }, deps);
87
+ if (result.kind === 'aborted') {
88
+ this.log(`Aborted: ${result.reason}`);
89
+ return { aborted: true, reason: result.reason };
90
+ }
91
+ if (result.kind === 'no-op') {
92
+ this.log(result.reason);
93
+ return { noop: true, reason: result.reason };
94
+ }
95
+ this.log(`Pushed via ${result.dispatchedAs}.`);
96
+ return { pushed: true, via: result.dispatchedAs, commitSha: result.commitSha ?? null };
97
+ }
98
+ catch (error) {
99
+ if (error instanceof PushFatalError) {
100
+ return this.err(error.message);
101
+ }
102
+ throw error;
103
+ }
104
+ finally {
105
+ await cleanup();
106
+ }
107
+ }
108
+ }
109
+ /**
110
+ * Map a raw `gitea.token` API response into the `GiteaTokenMintResult` shape
111
+ * the push core (`runPush`) consumes. Kept as a pure function so we can unit
112
+ * test the wiring without a network — specifically, to regression-test that
113
+ * `workspaceId` on the result is the UUID the server returned, not the slug.
114
+ *
115
+ * The bug this guards against: before the backend started returning
116
+ * `workspaceId`, this module used `resp.workspaceSlug` as the `workspaceId`
117
+ * field, which caused `checkIdentity` to fail when the user passed
118
+ * `--workspace <UUID>` (requested=UUID, backend.workspaceId=slug → mismatch).
119
+ */
120
+ export function giteaResponseToMintResult(resp) {
121
+ return {
122
+ token: resp.token,
123
+ repoUrl: resp.repoUrl,
124
+ expiresAt: resp.expiresAt,
125
+ workspaceSlug: resp.workspaceSlug,
126
+ // Canonical workspace UUID from the server. checkIdentity compares this
127
+ // against `requestedTarget` — so if the user passed --workspace as a
128
+ // UUID, the UUID path matches backend.workspaceId; if they passed a
129
+ // slug, the slug path matches backend.workspaceSlug. Either way the
130
+ // backend row is the same workspace.
131
+ workspaceId: resp.workspaceId,
132
+ };
133
+ }
134
+ /**
135
+ * Wire the real implementations of every dependency runPush needs. Kept in
136
+ * the command file so the pure core stays free of oclif / git shell imports.
137
+ *
138
+ * Returns a `{deps, cleanup}` pair. The caller MUST call `cleanup()` in a
139
+ * finally block so the bare-path probe clone is removed even on errors.
140
+ */
141
+ export function buildRealDeps(cmd) {
142
+ const log = (line) => cmd.log(line);
143
+ const errLog = (line) => cmd.logToStderr(line);
144
+ // State captured across calls so a bare-path diff can share its probe clone
145
+ // between `diffHeadVsOrigin` and `countFilesInRemote`. Populated lazily on
146
+ // the first call that needs it.
147
+ //
148
+ // `authenticatedRepoUrl` is set by the wrapped `mintWriteToken` below —
149
+ // runPush always calls mint before it calls any gitOps method that needs
150
+ // the probe clone, so by the time we read it here it is populated.
151
+ let authenticatedRepoUrl;
152
+ let barePathProbe;
153
+ const ensureBarePathProbe = async (dir) => {
154
+ if (barePathProbe)
155
+ return barePathProbe;
156
+ if (!authenticatedRepoUrl) {
157
+ throw new Error('buildRealDeps: bare-path diff requested before mintWriteToken ran. This is a bug in runPush ordering.');
158
+ }
159
+ barePathProbe = await computeBarePathDiff(dir, authenticatedRepoUrl);
160
+ return barePathProbe;
161
+ };
162
+ const gitOps = {
163
+ async isGitRepo(dir) {
164
+ return isGitRepo(dir);
165
+ },
166
+ async getRemoteOriginUrl(dir) {
167
+ const url = await getRemoteUrl(dir);
168
+ return url ?? undefined;
169
+ },
170
+ async setRemoteOrigin(dir, url) {
171
+ await gitSetRemote(dir, url);
172
+ },
173
+ async fetch(dir) {
174
+ // fetch is only meaningful for the clone path. For a bare-path dir it
175
+ // is still called by runPush but the dir has no origin, so we swallow
176
+ // the failure here rather than in runPush. The probe-clone strategy
177
+ // handles the remote-state read.
178
+ try {
179
+ await gitFetch(dir);
180
+ }
181
+ catch {
182
+ /* bare path: no origin to fetch from — intentional no-op */
183
+ }
184
+ },
185
+ async diffHeadVsOrigin(dir) {
186
+ // Clone path: .git/ exists AND origin matches backend. Prefer the native
187
+ // git diff; it is fast and doesn't require a second clone.
188
+ if (await isGitRepo(dir)) {
189
+ try {
190
+ return await diffHeadVsOrigin(dir);
191
+ }
192
+ catch {
193
+ // Fall through to the probe-clone path. This can happen if the dir
194
+ // is a git repo but origin doesn't have a main branch fetched, etc.
195
+ }
196
+ }
197
+ const probe = await ensureBarePathProbe(dir);
198
+ return probe.deltas;
199
+ },
200
+ async push(dir) {
201
+ await execFile('git', ['-C', dir, 'push', 'origin', 'main']);
202
+ },
203
+ async getLocalAuthor(dir) {
204
+ return readLocalAuthor(dir);
205
+ },
206
+ async countFilesInRemote(dir) {
207
+ if (await isGitRepo(dir)) {
208
+ const fromGit = await countTrackedFilesAtRef(dir, 'origin/main');
209
+ // If origin/main is present we get a positive count; if the dir is a
210
+ // bare path with a stale .git we get 0 and fall through.
211
+ if (fromGit > 0)
212
+ return fromGit;
213
+ }
214
+ const probe = await ensureBarePathProbe(dir);
215
+ return probe.totalRemoteFiles;
216
+ },
217
+ };
218
+ const deps = {
219
+ async mintWriteToken(requestedTarget) {
220
+ const resp = await mintGiteaToken(requestedTarget, 'write', 'push');
221
+ // Stash the authenticated URL so the bare-path gitOps methods can
222
+ // probe-clone it without a second mint.
223
+ authenticatedRepoUrl = resp.repoUrl;
224
+ return giteaResponseToMintResult(resp);
225
+ },
226
+ async validate(dir) {
227
+ const { validateWorkspace } = await import('../verify/validate.js');
228
+ const result = validateWorkspace(dir);
229
+ const errors = result.issues.filter((i) => i.severity === 'error').map((i) => i.message);
230
+ return { errors };
231
+ },
232
+ gitOps,
233
+ copyDirMirror,
234
+ log,
235
+ errLog,
236
+ };
237
+ const cleanup = async () => {
238
+ if (!barePathProbe)
239
+ return;
240
+ try {
241
+ fs.rmSync(barePathProbe.scratchDir, { force: true, recursive: true });
242
+ }
243
+ catch {
244
+ /* ignore — cleanup is best-effort */
245
+ }
246
+ };
247
+ return { deps, cleanup };
248
+ }
249
+ /**
250
+ * Diff the working tree (what we are about to push) against origin/main. We
251
+ * use `git diff --name-status HEAD..origin/main` with inverted semantics:
252
+ * Added here = present locally (in HEAD), absent in origin/main.
253
+ * Deleted = absent locally, present in origin/main.
254
+ * Modified = content differs.
255
+ *
256
+ * `git diff A..B` reports B relative to A. So `HEAD..origin/main` tells us
257
+ * what origin has that we don't. We want "local vs remote" with local as the
258
+ * baseline — i.e. origin/main..HEAD.
259
+ */
260
+ async function diffHeadVsOrigin(dir) {
261
+ // origin/main..HEAD reports changes HEAD has relative to origin/main.
262
+ // 'A' -> added locally, 'D' -> deleted locally, 'M' -> modified locally.
263
+ const { stdout } = await execFile('git', [
264
+ '-C',
265
+ dir,
266
+ 'diff',
267
+ '--name-status',
268
+ 'origin/main..HEAD',
269
+ ]);
270
+ const deltas = [];
271
+ for (const raw of stdout.split('\n')) {
272
+ const line = raw.trim();
273
+ if (!line)
274
+ continue;
275
+ const [status, ...rest] = line.split(/\s+/);
276
+ const pathStr = rest.join(' ');
277
+ if (!pathStr)
278
+ continue;
279
+ if (status.startsWith('A'))
280
+ deltas.push({ kind: 'added', path: pathStr });
281
+ else if (status.startsWith('D'))
282
+ deltas.push({ kind: 'deleted', path: pathStr });
283
+ else if (status.startsWith('M') || status.startsWith('R') || status.startsWith('C')) {
284
+ // Treat renames / copies as modifications for diff-summary purposes.
285
+ deltas.push({ kind: 'modified', path: pathStr });
286
+ }
287
+ }
288
+ return deltas;
289
+ }
290
+ async function countTrackedFilesAtRef(dir, ref) {
291
+ try {
292
+ const { stdout } = await execFile('git', ['-C', dir, 'ls-tree', '-r', '--name-only', ref]);
293
+ return stdout.split('\n').filter((l) => l.trim().length > 0).length;
294
+ }
295
+ catch {
296
+ return 0;
297
+ }
298
+ }
299
+ async function readLocalAuthor(dir) {
300
+ try {
301
+ const { stdout: name } = await execFile('git', ['-C', dir, 'config', 'user.name']);
302
+ const { stdout: email } = await execFile('git', ['-C', dir, 'config', 'user.email']);
303
+ const n = name.trim();
304
+ const e = email.trim();
305
+ if (!n || !e)
306
+ return undefined;
307
+ return { name: n, email: e };
308
+ }
309
+ catch {
310
+ return undefined;
311
+ }
312
+ }
313
+ /**
314
+ * Copy the contents of `sourceDir` into `destDir`, skipping `.git/` and any
315
+ * temp push-clone scratch directories. Used as the `applyDelta` callback for
316
+ * bare-path pushes — we mirror local files into a fresh clone so that
317
+ * `cloneAndStackPush`'s `git add --all` + diff will reflect the union of
318
+ * (deletes, modifies, adds) we intend.
319
+ *
320
+ * IMPORTANT: this deletes files in destDir that are not in sourceDir so that
321
+ * deletions make it into the push. Without this the clone-and-stack produces
322
+ * an "adds-only" delta and destructive heuristics would never fire in the UI.
323
+ */
324
+ export async function copyDirMirror(sourceDir, destDir) {
325
+ // Collect source entries (except .git, .quonfig-push-clone-*)
326
+ const sourceRel = collectRelPaths(sourceDir);
327
+ const destRel = collectRelPaths(destDir);
328
+ // Delete anything in dest that isn't in source
329
+ for (const rel of destRel) {
330
+ if (!sourceRel.has(rel)) {
331
+ const full = path.join(destDir, rel);
332
+ try {
333
+ fs.rmSync(full, { force: true });
334
+ }
335
+ catch {
336
+ /* ignore */
337
+ }
338
+ }
339
+ }
340
+ // Copy / overwrite everything from source into dest
341
+ for (const rel of sourceRel) {
342
+ const src = path.join(sourceDir, rel);
343
+ const dst = path.join(destDir, rel);
344
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
345
+ fs.copyFileSync(src, dst);
346
+ }
347
+ }
348
+ function collectRelPaths(root) {
349
+ const out = new Set();
350
+ if (!fs.existsSync(root))
351
+ return out;
352
+ const walk = (dir, prefix) => {
353
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
354
+ if (entry.name === '.git')
355
+ continue;
356
+ if (entry.name.startsWith('.quonfig-push-clone-'))
357
+ continue;
358
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
359
+ const full = path.join(dir, entry.name);
360
+ if (entry.isDirectory()) {
361
+ walk(full, rel);
362
+ }
363
+ else if (entry.isFile() || entry.isSymbolicLink()) {
364
+ out.add(rel);
365
+ }
366
+ }
367
+ };
368
+ walk(root, '');
369
+ return out;
370
+ }
@@ -9,5 +9,12 @@ export default class WorkspaceBootstrap extends BaseCommand {
9
9
  'skip-validate': import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
10
10
  };
11
11
  run(): Promise<JsonObj | void>;
12
+ /**
13
+ * Stage, commit, and push the `quonfig.json` pin. Runs after the main
14
+ * bootstrap push so the pin lands as a committed+pushed change rather than
15
+ * an uncommitted local edit. We stage only `quonfig.json` (not `-A`) so we
16
+ * don't accidentally sweep in unrelated user edits.
17
+ */
18
+ private commitAndPushPin;
12
19
  private remoteHasCommits;
13
20
  }
@@ -4,6 +4,7 @@ import { Flags } from '@oclif/core';
4
4
  import { BaseCommand } from '../../index.js';
5
5
  import { getActiveProfile, loadAuthConfig } from '../../util/token-storage.js';
6
6
  import { mintGiteaToken } from '../../util/gitea-api.js';
7
+ import { readWorkspaceSlug, writeWorkspaceSlug } from '../../util/quonfig-json.js';
7
8
  import { isGitRepo, hasAtLeastOneCommit, gitSetRemote, gitPushForceLease, gitPushForce, getRemoteUrl, displayUrl, } from '../../util/git-ops.js';
8
9
  export default class WorkspaceBootstrap extends BaseCommand {
9
10
  static description = "Push a local git repo to Gitea as this workspace's config repository";
@@ -102,8 +103,8 @@ export default class WorkspaceBootstrap extends BaseCommand {
102
103
  catch (error) {
103
104
  return this.err(`Could not get Gitea credentials: ${String(error)}\n\nMake sure the workspace is fully provisioned in the Quonfig app before bootstrapping.`);
104
105
  }
105
- const { repoUrl } = tokenData;
106
- this.verboseLog('WorkspaceBootstrap', { repoUrl: displayUrl(repoUrl) });
106
+ const { repoUrl, workspaceSlug: backendSlug } = tokenData;
107
+ this.verboseLog('WorkspaceBootstrap', { repoUrl: displayUrl(repoUrl), backendSlug });
107
108
  // Idempotency: check if remote already has commits
108
109
  if (!flags.force) {
109
110
  const remoteHasCommits = await this.remoteHasCommits(repoUrl);
@@ -135,6 +136,35 @@ export default class WorkspaceBootstrap extends BaseCommand {
135
136
  catch (error) {
136
137
  return this.err(`Push failed: ${String(error)}`);
137
138
  }
139
+ // Write the workspace pin into `quonfig.json` (Guard 1 in
140
+ // project/plans/cli-git-sync.md). If the pin already exists and disagrees,
141
+ // we keep what is there — bootstrap is for fresh cases, not re-pinning.
142
+ // If we do write it, commit and push so the bootstrap leaves no
143
+ // uncommitted changes behind.
144
+ try {
145
+ const existingPin = await readWorkspaceSlug(resolvedDir);
146
+ if (existingPin && existingPin !== backendSlug) {
147
+ this.log('');
148
+ this.log(`Warning: quonfig.json already pins workspace "${existingPin}", but the backend says this workspace is "${backendSlug}".`);
149
+ this.log('Leaving the existing pin in place. If this is wrong, edit quonfig.json manually and re-run.');
150
+ }
151
+ else if (existingPin === backendSlug) {
152
+ this.verboseLog('WorkspaceBootstrap', `quonfig.json already pinned to ${backendSlug}; no-op.`);
153
+ }
154
+ else {
155
+ this.log('');
156
+ this.log(`Pinning quonfig.json to workspace "${backendSlug}"...`);
157
+ await writeWorkspaceSlug(resolvedDir, backendSlug);
158
+ await this.commitAndPushPin(resolvedDir, backendSlug, flags.force);
159
+ }
160
+ }
161
+ catch (error) {
162
+ // The main push has already succeeded, so don't fail the whole command.
163
+ // Surface the issue clearly so the user knows to follow up.
164
+ this.log('');
165
+ this.log(`Warning: could not write the workspace pin to quonfig.json: ${String(error)}`);
166
+ this.log('The workspace is connected, but you should re-run `qfg pull` to backfill the pin.');
167
+ }
138
168
  this.log(`\nBootstrap complete.`);
139
169
  this.log(`Workspace "${workspaceName}" is now connected to your local directory.`);
140
170
  this.log(`\nTo keep it in sync locally, run:`);
@@ -145,6 +175,32 @@ export default class WorkspaceBootstrap extends BaseCommand {
145
175
  workspaceId,
146
176
  };
147
177
  }
178
+ /**
179
+ * Stage, commit, and push the `quonfig.json` pin. Runs after the main
180
+ * bootstrap push so the pin lands as a committed+pushed change rather than
181
+ * an uncommitted local edit. We stage only `quonfig.json` (not `-A`) so we
182
+ * don't accidentally sweep in unrelated user edits.
183
+ */
184
+ async commitAndPushPin(dir, slug, force) {
185
+ const { execFile: execFileCb } = await import('node:child_process');
186
+ const util = await import('node:util');
187
+ const execFile = util.promisify(execFileCb);
188
+ // Stage just quonfig.json so we don't accidentally commit other dirty files.
189
+ await execFile('git', ['-C', dir, 'add', 'quonfig.json']);
190
+ // If `git add` produced no staged change (e.g. content matched an earlier
191
+ // version on disk), `git commit` would fail. Check the index first.
192
+ const { stdout: diffStat } = await execFile('git', ['-C', dir, 'diff', '--cached', '--name-only']);
193
+ if (!diffStat.trim()) {
194
+ this.verboseLog('WorkspaceBootstrap', 'quonfig.json pin matches HEAD; no commit needed.');
195
+ return;
196
+ }
197
+ await execFile('git', ['-C', dir, 'commit', '-m', `chore: pin quonfig.json to workspace "${slug}"`]);
198
+ // Push the new commit using the same force semantics as the main push.
199
+ const pushArgs = ['-C', dir, 'push', 'origin', 'main'];
200
+ pushArgs.push(force ? '--force' : '--force-with-lease');
201
+ await execFile('git', pushArgs);
202
+ this.log(`Pushed pin commit.`);
203
+ }
148
204
  async remoteHasCommits(repoUrl) {
149
205
  const { execFile: execFileCb } = await import('node:child_process');
150
206
  const util = await import('node:util');
@@ -1,7 +1,7 @@
1
1
  import { type ImportState } from './import-state.js';
2
2
  import { type MigrationReportData } from './migration-report.js';
3
- import { type PushIdentity } from './push-strategy.js';
4
- import type { LegacyChange, MigrationSource } from './source.js';
3
+ import { type PushIdentity } from '../util/clone-and-stack-push.js';
4
+ import type { DroppedOverrideSummary, DuplicateResolutionSummary, LegacyChange, MigrationSource, SkippedConfigSummary } from './source.js';
5
5
  export interface ApplyLocalMigrationOptions {
6
6
  /** Commit author. Defaults to the migrator identity. */
7
7
  author?: PushIdentity;
@@ -22,5 +22,11 @@ export interface ApplyLocalMigrationResult {
22
22
  commitSha: string | null;
23
23
  /** `false` if translate produced no net changes — nothing was committed. */
24
24
  committed: boolean;
25
+ /** Override sections dropped during translate because env.id was unknown. Null if none. */
26
+ droppedOverrides: DroppedOverrideSummary | null;
27
+ /** Cross-type key collisions resolved by preferring the config side. Null if none. */
28
+ duplicateResolutions: DuplicateResolutionSummary | null;
29
+ /** Configs soft-skipped during translate due to invalid source data. Null if none. */
30
+ skippedConfigs: SkippedConfigSummary | null;
25
31
  }
26
32
  export declare const applyLocalMigration: (opts: ApplyLocalMigrationOptions) => Promise<ApplyLocalMigrationResult>;
@@ -4,7 +4,8 @@ import * as path from 'node:path';
4
4
  import * as util from 'node:util';
5
5
  import { removeQfFromGitignore, writeImportState } from './import-state.js';
6
6
  import { writeMigrationReport } from './migration-report.js';
7
- import { MIGRATOR_IDENTITY } from './push-strategy.js';
7
+ import { detectDuplicateKeys } from './sources/launch/translate.js';
8
+ import { MIGRATOR_IDENTITY } from '../util/clone-and-stack-push.js';
8
9
  const execFile = util.promisify(execFileCb);
9
10
  const isGitRepo = async (dir) => {
10
11
  try {
@@ -49,14 +50,45 @@ const ensureLocalRepo = async (localDir, branch) => {
49
50
  return 'initialized';
50
51
  };
51
52
  const writeQuonfigFiles = (dir, changes, source) => {
53
+ const livePaths = new Map();
52
54
  for (const change of changes) {
53
55
  const files = source.translate(change);
54
56
  for (const file of files) {
55
57
  const full = path.join(dir, file.path);
56
- fs.mkdirSync(path.dirname(full), { recursive: true });
57
- fs.writeFileSync(full, file.contents);
58
+ if (file.deleted) {
59
+ try {
60
+ fs.unlinkSync(full);
61
+ }
62
+ catch (error) {
63
+ if (error.code !== 'ENOENT')
64
+ throw error;
65
+ }
66
+ livePaths.delete(file.path);
67
+ }
68
+ else {
69
+ if (file.contents === undefined) {
70
+ throw new Error(`translate() emitted a write op for ${file.path} without contents`);
71
+ }
72
+ fs.mkdirSync(path.dirname(full), { recursive: true });
73
+ fs.writeFileSync(full, file.contents);
74
+ livePaths.set(file.path, true);
75
+ }
58
76
  }
59
77
  }
78
+ const resolutions = detectDuplicateKeys([...livePaths.keys()].map((p) => ({ path: p })));
79
+ for (const resolution of resolutions) {
80
+ for (const toDelete of resolution.deleted) {
81
+ try {
82
+ fs.unlinkSync(path.join(dir, toDelete));
83
+ }
84
+ catch (error) {
85
+ if (error.code !== 'ENOENT')
86
+ throw error;
87
+ }
88
+ livePaths.delete(toDelete);
89
+ }
90
+ }
91
+ return resolutions;
60
92
  };
61
93
  export const applyLocalMigration = async (opts) => {
62
94
  const branch = opts.branch ?? 'main';
@@ -65,13 +97,22 @@ export const applyLocalMigration = async (opts) => {
65
97
  await execFile('git', ['-C', opts.localDir, 'config', 'user.name', author.name]);
66
98
  await execFile('git', ['-C', opts.localDir, 'config', 'user.email', author.email]);
67
99
  ensureQuonfigJson(opts.localDir, opts.environments);
68
- writeQuonfigFiles(opts.localDir, opts.changes, opts.source);
100
+ const resolutionEntries = writeQuonfigFiles(opts.localDir, opts.changes, opts.source);
69
101
  removeQfFromGitignore(opts.localDir);
70
102
  writeImportState(opts.localDir, opts.importState);
71
- writeMigrationReport(opts.localDir, opts.reportData);
103
+ const droppedOverrides = opts.source.getDroppedOverrides?.() ?? null;
104
+ const skippedConfigs = opts.source.getSkippedConfigs?.() ?? null;
105
+ const duplicateResolutions = resolutionEntries.length > 0 ? { entries: resolutionEntries, total: resolutionEntries.length } : null;
106
+ const reportData = {
107
+ ...opts.reportData,
108
+ ...(droppedOverrides ? { droppedOverrides } : {}),
109
+ ...(duplicateResolutions ? { duplicateResolutions } : {}),
110
+ ...(skippedConfigs ? { skippedConfigs } : {}),
111
+ };
112
+ writeMigrationReport(opts.localDir, reportData);
72
113
  await execFile('git', ['-C', opts.localDir, 'add', '--all']);
73
114
  if (!(await hasStagedChanges(opts.localDir))) {
74
- return { action, committed: false, commitSha: null };
115
+ return { action, committed: false, commitSha: null, droppedOverrides, duplicateResolutions, skippedConfigs };
75
116
  }
76
117
  await gitCommitFromStdin(opts.localDir, opts.commitMessage, {
77
118
  ...process.env,
@@ -81,5 +122,12 @@ export const applyLocalMigration = async (opts) => {
81
122
  GIT_COMMITTER_NAME: author.name,
82
123
  });
83
124
  const { stdout: sha } = await execFile('git', ['-C', opts.localDir, 'rev-parse', 'HEAD']);
84
- return { action, commitSha: sha.trim(), committed: true };
125
+ return {
126
+ action,
127
+ commitSha: sha.trim(),
128
+ committed: true,
129
+ droppedOverrides,
130
+ duplicateResolutions,
131
+ skippedConfigs,
132
+ };
85
133
  };