@quonfig/cli 0.0.74 → 0.2.0

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 (35) hide show
  1. package/README.md +51 -51
  2. package/dist/codegen/json-schema-to-zod.js +127 -22
  3. package/dist/codegen/language-mappers/zod-base-mapper.d.ts +11 -1
  4. package/dist/codegen/language-mappers/zod-base-mapper.js +14 -2
  5. package/dist/codegen/language-mappers/zod-to-string-mapper.d.ts +2 -1
  6. package/dist/codegen/language-mappers/zod-to-string-mapper.js +6 -2
  7. package/dist/codegen/language-mappers/zod-to-typescript-mapper.d.ts +6 -2
  8. package/dist/codegen/language-mappers/zod-to-typescript-mapper.js +34 -5
  9. package/dist/codegen/language-mappers/zod-to-typescript-return-value-mapper.d.ts +6 -3
  10. package/dist/codegen/language-mappers/zod-to-typescript-return-value-mapper.js +22 -11
  11. package/dist/codegen/zod-introspection.d.ts +12 -0
  12. package/dist/codegen/zod-introspection.js +15 -0
  13. package/dist/commands/push.js +1 -1
  14. package/dist/commands/verify.js +1 -1
  15. package/dist/commands/workspace/bootstrap.d.ts +0 -8
  16. package/dist/commands/workspace/bootstrap.js +88 -96
  17. package/dist/migrate/push-to-cloud.js +1 -1
  18. package/dist/util/bootstrap-target.d.ts +40 -0
  19. package/dist/util/bootstrap-target.js +46 -0
  20. package/dist/util/clone-and-stack-push.d.ts +1 -4
  21. package/dist/util/clone-and-stack-push.js +2 -5
  22. package/dist/util/git-ops.d.ts +60 -2
  23. package/dist/util/git-ops.js +303 -5
  24. package/dist/verify/schema-error-reshape.d.ts +69 -0
  25. package/dist/verify/schema-error-reshape.js +257 -0
  26. package/dist/verify/schema-validator.d.ts +29 -0
  27. package/dist/verify/schema-validator.js +108 -0
  28. package/dist/verify/standalone.d.ts +31 -0
  29. package/dist/verify/standalone.js +156 -11
  30. package/dist/verify/validate.d.ts +16 -2
  31. package/dist/verify/validate.js +95 -5
  32. package/dist/version.d.ts +1 -1
  33. package/dist/version.js +1 -1
  34. package/oclif.manifest.json +2 -2
  35. package/package.json +3 -1
@@ -0,0 +1,40 @@
1
+ /**
2
+ * What `qfg workspace bootstrap` tells the user about the workspace it is
3
+ * about to push to, and the one check that stops it landing content in the
4
+ * wrong place.
5
+ *
6
+ * The target is resolved the same way `qfg push` / `qfg pull` / `qfg sync`
7
+ * resolve theirs: `QUONFIG_WORKSPACE`, then the directory's `quonfig.json`
8
+ * pin, then the active profile. The prompt must name whichever of those won;
9
+ * naming the profile when the env var chose another workspace is how a
10
+ * customer confirms a push to the wrong place (qfg-8p8i).
11
+ */
12
+ import type { WorkspacePin } from './quonfig-json.js';
13
+ export interface BootstrapProfile {
14
+ organizationSlug?: string;
15
+ workspace: string;
16
+ workspaceName?: string;
17
+ workspaceSlug?: string;
18
+ }
19
+ export interface BootstrapTargetInput {
20
+ /** `QUONFIG_WORKSPACE`, when set: it outranks everything else. */
21
+ envOverride: string | undefined;
22
+ /** The `--dir` directory's `quonfig.json` pin, when readable. */
23
+ pin: WorkspacePin | undefined;
24
+ /** The active profile, if any. Only trusted when it IS the resolved workspace. */
25
+ profile: BootstrapProfile | undefined;
26
+ /** What the resolver actually returned. */
27
+ workspaceId: string;
28
+ }
29
+ /**
30
+ * The name to show for the resolved workspace, from whichever source chose
31
+ * it. Falls back to the id rather than to a profile that points elsewhere.
32
+ */
33
+ export declare const describeBootstrapTarget: (input: BootstrapTargetInput) => string;
34
+ /**
35
+ * `qfg push`'s Guard 1 for bootstrap: a directory pinned to one workspace is
36
+ * not pushed to another. Compares the workspace component only, as push does
37
+ * (backend responses do not carry the org slug yet). Returns the refusal
38
+ * message, or nothing when there is no pin or it matches.
39
+ */
40
+ export declare const bootstrapPinMismatch: (pin: WorkspacePin | undefined, backendWorkspaceSlug: string) => string | undefined;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * What `qfg workspace bootstrap` tells the user about the workspace it is
3
+ * about to push to, and the one check that stops it landing content in the
4
+ * wrong place.
5
+ *
6
+ * The target is resolved the same way `qfg push` / `qfg pull` / `qfg sync`
7
+ * resolve theirs: `QUONFIG_WORKSPACE`, then the directory's `quonfig.json`
8
+ * pin, then the active profile. The prompt must name whichever of those won;
9
+ * naming the profile when the env var chose another workspace is how a
10
+ * customer confirms a push to the wrong place (qfg-8p8i).
11
+ */
12
+ /** The pin form of a workspace, `<org>/<workspace>`. */
13
+ const pinForm = (pin) => `${pin.orgSlug}/${pin.workspaceSlug}`;
14
+ /**
15
+ * The name to show for the resolved workspace, from whichever source chose
16
+ * it. Falls back to the id rather than to a profile that points elsewhere.
17
+ */
18
+ export const describeBootstrapTarget = (input) => {
19
+ if (input.envOverride)
20
+ return input.envOverride;
21
+ if (input.pin)
22
+ return pinForm(input.pin);
23
+ const { profile } = input;
24
+ if (profile && profile.workspace === input.workspaceId) {
25
+ if (profile.workspaceSlug) {
26
+ return profile.organizationSlug ? `${profile.organizationSlug}/${profile.workspaceSlug}` : profile.workspaceSlug;
27
+ }
28
+ if (profile.workspaceName)
29
+ return profile.workspaceName;
30
+ }
31
+ return input.workspaceId;
32
+ };
33
+ /**
34
+ * `qfg push`'s Guard 1 for bootstrap: a directory pinned to one workspace is
35
+ * not pushed to another. Compares the workspace component only, as push does
36
+ * (backend responses do not carry the org slug yet). Returns the refusal
37
+ * message, or nothing when there is no pin or it matches.
38
+ */
39
+ export const bootstrapPinMismatch = (pin, backendWorkspaceSlug) => {
40
+ if (!pin || pin.workspaceSlug === backendWorkspaceSlug)
41
+ return undefined;
42
+ return (`This directory's quonfig.json is pinned to workspace "${pinForm(pin)}", ` +
43
+ `but the target workspace is "${backendWorkspaceSlug}".\n` +
44
+ `Nothing was pushed. Unset QUONFIG_WORKSPACE to bootstrap the pinned workspace, ` +
45
+ `or change the pin in quonfig.json if "${backendWorkspaceSlug}" is really the target.`);
46
+ };
@@ -1,7 +1,4 @@
1
- export declare const MIGRATOR_IDENTITY: {
2
- readonly name: "quonfig migrator";
3
- readonly email: "migrator@quonfig.com";
4
- };
1
+ export { MIGRATOR_IDENTITY } from './git-ops.js';
5
2
  export interface PushIdentity {
6
3
  email: string;
7
4
  name: string;
@@ -1,10 +1,7 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
- import { runGit as runGitSafe, spawnGit } from './git-ops.js';
4
- export const MIGRATOR_IDENTITY = {
5
- name: 'quonfig migrator',
6
- email: 'migrator@quonfig.com',
7
- };
3
+ import { MIGRATOR_IDENTITY, runGit as runGitSafe, spawnGit } from './git-ops.js';
4
+ export { MIGRATOR_IDENTITY } from './git-ops.js';
8
5
  const redactToken = (s) => s.replaceAll(/:([^\s/@]+)@/g, ':***@');
9
6
  export class PushConflictError extends Error {
10
7
  constructor(message) {
@@ -142,8 +142,66 @@ export declare const gitMergeFfOnly: (dir: string) => Promise<string[]>;
142
142
  * Adds or updates the origin remote for a repo.
143
143
  */
144
144
  export declare const gitSetRemote: (dir: string, url: string) => Promise<void>;
145
- export declare const gitPushForceLease: (dir: string) => Promise<void>;
146
- export declare const gitPushForce: (dir: string) => Promise<void>;
145
+ /**
146
+ * Every file the validator owns at `ref` — config documents (`configs/`,
147
+ * `feature-flags/`, `segments/`, `log-levels/`) AND schemas (`schemas/`,
148
+ * `schemas-protected/`) — sorted.
149
+ *
150
+ * A workspace repo is FRESH exactly when this list is empty. It is NOT "the
151
+ * remote holds only the auto-init commit": provisioning commits `README.md`
152
+ * AND `quonfig.json`, so a commit-count test refuses every real workspace
153
+ * (plan project/plans/2026-09-17-tree-derived-cache.md 5.6, 13.2 risk 5).
154
+ * Schemas count because a UI-authored schema is content a local-wins rebase
155
+ * would silently overwrite, exactly like a flag.
156
+ *
157
+ * FAILS CLOSED: if the ref cannot be listed this throws. "I could not look"
158
+ * must never read as "the workspace is empty".
159
+ */
160
+ export declare const workspaceDocumentsAtRef: (dir: string, ref: string) => Promise<string[]>;
161
+ export interface RebaseOntoOriginResult {
162
+ /** How many of the customer's commits were replayed onto the remote head. */
163
+ commitsRebased: number;
164
+ /** The sha now at `origin/main`. */
165
+ pushedSha: string;
166
+ /** Subject of the extra commit that put the tree right, or null if none was needed. */
167
+ reconcileSubject: null | string;
168
+ }
169
+ /**
170
+ * The identity the CLI commits as when nothing better is known: `qfg push`
171
+ * and `qfg migrate` stack their commits under it, and bootstrap's replayed
172
+ * commits get it as COMMITTER when the machine has no git identity at all.
173
+ */
174
+ export declare const MIGRATOR_IDENTITY: {
175
+ readonly name: "quonfig migrator";
176
+ readonly email: "migrator@quonfig.com";
177
+ };
178
+ /**
179
+ * Land the local repo's history on `origin/main` WITHOUT ever force-pushing
180
+ * (plan 5.6 "Bootstrap keeps its history without force"; `main` is
181
+ * append-only, so a rewrite is not just forbidden, it silently wedges config
182
+ * delivery).
183
+ *
184
+ * `git fetch origin` is the caller's job. Then:
185
+ *
186
+ * 1. Replay the whole local history onto the remote head on a TEMPORARY
187
+ * worktree — never on the customer's branch. Whatever happens, their
188
+ * local refs and working tree are untouched.
189
+ * 2. Require the replayed tip's tree to equal the local tip's tree, ignoring
190
+ * files the workspace seeded that the local repo does not have. Rebasing
191
+ * linearises merges and `-X theirs` silently picks a side, so a
192
+ * hand-resolved merge conflict can otherwise ship the wrong content with
193
+ * exit 0 (13.2 risk 4).
194
+ * 3. If it differs, add ONE commit that sets the tree to the local tip's
195
+ * tree, keeping the seeded files and the workspace's `quonfig.json`. If
196
+ * that cannot be done cleanly, abort with nothing pushed.
197
+ * 4. Validate the tree that is ACTUALLY about to be pushed (unless
198
+ * `validate: false`), so content the workspace would reject fails here
199
+ * instead of at the server hook.
200
+ * 5. Plain `git push` of the result to `main`.
201
+ */
202
+ export declare const rebaseOntoOriginAndPush: (dir: string, opts?: {
203
+ validate?: boolean;
204
+ }) => Promise<RebaseOntoOriginResult>;
147
205
  export declare const hasAtLeastOneCommit: (dir: string) => Promise<boolean>;
148
206
  /**
149
207
  * Get the URL stripped of credentials for display purposes.
@@ -1,4 +1,4 @@
1
- import { execFile as execFileCb, spawn } from 'node:child_process';
1
+ import { execFile as execFileCb, execFileSync, spawn } from 'node:child_process';
2
2
  import * as util from 'node:util';
3
3
  const execFile = util.promisify(execFileCb);
4
4
  /**
@@ -365,11 +365,309 @@ export const gitSetRemote = async (dir, url) => {
365
365
  await runGit(['-C', dir, 'remote', 'set-url', 'origin', url]);
366
366
  }
367
367
  };
368
- export const gitPushForceLease = async (dir) => {
369
- await runGit(['-C', dir, 'push', 'origin', 'main', '--force-with-lease']);
368
+ const revParse = async (dir, rev) => {
369
+ try {
370
+ const { stdout } = await runGit(['-C', dir, 'rev-parse', '--verify', '--quiet', rev]);
371
+ return stdout.trim() || null;
372
+ }
373
+ catch {
374
+ return null;
375
+ }
376
+ };
377
+ const treePaths = async (dir, rev) => {
378
+ const { stdout } = await runGit(['-C', dir, 'ls-tree', '-r', '--name-only', rev]);
379
+ return stdout
380
+ .split('\n')
381
+ .map((s) => s.trim())
382
+ .filter(Boolean);
383
+ };
384
+ const countCommits = async (dir, rev) => {
385
+ const { stdout } = await runGit(['-C', dir, 'rev-list', '--count', rev]);
386
+ return Number.parseInt(stdout.trim(), 10) || 0;
387
+ };
388
+ /**
389
+ * Every file the validator owns at `ref` — config documents (`configs/`,
390
+ * `feature-flags/`, `segments/`, `log-levels/`) AND schemas (`schemas/`,
391
+ * `schemas-protected/`) — sorted.
392
+ *
393
+ * A workspace repo is FRESH exactly when this list is empty. It is NOT "the
394
+ * remote holds only the auto-init commit": provisioning commits `README.md`
395
+ * AND `quonfig.json`, so a commit-count test refuses every real workspace
396
+ * (plan project/plans/2026-09-17-tree-derived-cache.md 5.6, 13.2 risk 5).
397
+ * Schemas count because a UI-authored schema is content a local-wins rebase
398
+ * would silently overwrite, exactly like a flag.
399
+ *
400
+ * FAILS CLOSED: if the ref cannot be listed this throws. "I could not look"
401
+ * must never read as "the workspace is empty".
402
+ */
403
+ export const workspaceDocumentsAtRef = async (dir, ref) => {
404
+ // Dynamic import: `src/verify/` is copied on its own into the standalone
405
+ // pre-receive-hook build, so it must stay self-contained — and pulling the
406
+ // validator in statically would load it for every command that touches git.
407
+ const { KNOWN_DIRS } = await import('../verify/validate.js');
408
+ const paths = await treePaths(dir, ref);
409
+ return paths.filter((p) => KNOWN_DIRS.has(p.split('/')[0])).sort();
410
+ };
411
+ /** Subject of the one commit that puts the tree back to the customer's. */
412
+ const RECONCILE_SUBJECT = 'reconcile merge resolutions';
413
+ /** Subject when the only thing put back is the workspace's own pin file. */
414
+ const PIN_SUBJECT = "use the hosted workspace's quonfig.json";
415
+ /**
416
+ * `-c` args that keep the CUSTOMER's git config out of the commits bootstrap
417
+ * makes in the temporary worktree. A worktree shares the repo's config, so
418
+ * `commit.gpgsign=true` with an unusable `gpg.program` fails the rebase and a
419
+ * failing `pre-commit` under `core.hooksPath` fails the reconcile commit (both
420
+ * verified). Neither commit is the customer's to sign or to gate: the rebase
421
+ * replays commits they already made, and the reconcile commit only restores
422
+ * their own tree.
423
+ */
424
+ const GIT_UNCONFIGURED_ARGS = [
425
+ '-c',
426
+ 'commit.gpgsign=false',
427
+ '-c',
428
+ 'tag.gpgsign=false',
429
+ '-c',
430
+ 'core.hooksPath=/dev/null',
431
+ '-c',
432
+ 'rebase.autosquash=false',
433
+ '-c',
434
+ 'rerere.enabled=false',
435
+ ];
436
+ /**
437
+ * The identity the CLI commits as when nothing better is known: `qfg push`
438
+ * and `qfg migrate` stack their commits under it, and bootstrap's replayed
439
+ * commits get it as COMMITTER when the machine has no git identity at all.
440
+ */
441
+ export const MIGRATOR_IDENTITY = {
442
+ name: 'quonfig migrator',
443
+ email: 'migrator@quonfig.com',
444
+ };
445
+ /**
446
+ * Env that gives git a committer when it cannot find one itself, and nothing
447
+ * otherwise. A rebase writes a committer on every replayed commit, and git
448
+ * refuses ("empty ident name ... not allowed") on a machine with no
449
+ * `user.name` / `user.email` and no guessable one — every GitHub runner, and
450
+ * a fresh laptop. `git var GIT_COMMITTER_IDENT` is git's own check, so a
451
+ * customer whose identity works keeps it, guessed or configured.
452
+ */
453
+ const committerFallbackEnv = async (dir) => {
454
+ try {
455
+ await runGit(['-C', dir, 'var', 'GIT_COMMITTER_IDENT']);
456
+ return {};
457
+ }
458
+ catch {
459
+ return {
460
+ GIT_AUTHOR_EMAIL: MIGRATOR_IDENTITY.email,
461
+ GIT_AUTHOR_NAME: MIGRATOR_IDENTITY.name,
462
+ GIT_COMMITTER_EMAIL: MIGRATOR_IDENTITY.email,
463
+ GIT_COMMITTER_NAME: MIGRATOR_IDENTITY.name,
464
+ };
465
+ }
466
+ };
467
+ const hasMismatch = (m) => m.pinLost || m.content.length > 0;
468
+ const describeMismatch = (m) => [...m.content, ...(m.pinLost ? ["quonfig.json (the workspace's pin was lost)"] : [])].join(', ');
469
+ const treeMismatch = async (dir, opts) => {
470
+ const { stdout } = await runGit(['-C', dir, 'diff', '--no-renames', '--name-status', opts.local, opts.candidate]);
471
+ const content = [];
472
+ for (const line of stdout.split('\n').filter(Boolean)) {
473
+ const [status, ...rest] = line.split('\t');
474
+ const file = rest.join('\t');
475
+ // A file only the workspace had is expected to appear.
476
+ if (status === 'A' && opts.seededOnly.has(file))
477
+ continue;
478
+ // The workspace's own quonfig.json wins over a local one: it is the pin
479
+ // that names the hosted workspace. Checked separately, below.
480
+ if (file === 'quonfig.json' && opts.remoteQuonfigJson !== null)
481
+ continue;
482
+ content.push(`${status} ${file}`);
483
+ }
484
+ let pinLost = false;
485
+ if (opts.remoteQuonfigJson !== null) {
486
+ const candidatePin = await revParse(dir, `${opts.candidate}:quonfig.json`);
487
+ pinLost = candidatePin !== opts.remoteQuonfigJson;
488
+ }
489
+ return { content, pinLost };
370
490
  };
371
- export const gitPushForce = async (dir) => {
372
- await runGit(['-C', dir, 'push', 'origin', 'main', '--force']);
491
+ const removeWorktree = async (dir, worktree) => {
492
+ try {
493
+ await runGit(['-C', dir, 'worktree', 'remove', '--force', worktree]);
494
+ }
495
+ catch {
496
+ /* already gone, or never created */
497
+ }
498
+ try {
499
+ await runGit(['-C', dir, 'worktree', 'prune']);
500
+ }
501
+ catch {
502
+ /* best effort */
503
+ }
504
+ };
505
+ /**
506
+ * Run the workspace validator — the same one the server's pre-receive hook
507
+ * runs — over a checked-out tree, and throw with its errors if it would be
508
+ * rejected.
509
+ */
510
+ const requireWorkspaceValidates = async (tree) => {
511
+ const { validateWorkspace } = await import('../verify/validate.js');
512
+ const errors = validateWorkspace(tree, { validateValues: true }).issues.filter((i) => i.severity === 'error');
513
+ if (errors.length === 0)
514
+ return;
515
+ const shown = errors.slice(0, 10).map((i) => ` ${i.file}: ${i.message}`);
516
+ if (errors.length > shown.length)
517
+ shown.push(` ...and ${errors.length - shown.length} more`);
518
+ const envHint = errors.some((i) => i.message.includes('not declared in quonfig.json'))
519
+ ? "\nThe workspace's own quonfig.json replaces yours, so an environment that exists only in your local file is not declared on the workspace. Create it in the Quonfig app (or drop the override), then run bootstrap again."
520
+ : '';
521
+ throw new Error(`Refusing to push: the workspace would reject this content.\n${shown.join('\n')}${envHint}\nNothing was pushed and your local repository is unchanged.`);
522
+ };
523
+ /**
524
+ * Land the local repo's history on `origin/main` WITHOUT ever force-pushing
525
+ * (plan 5.6 "Bootstrap keeps its history without force"; `main` is
526
+ * append-only, so a rewrite is not just forbidden, it silently wedges config
527
+ * delivery).
528
+ *
529
+ * `git fetch origin` is the caller's job. Then:
530
+ *
531
+ * 1. Replay the whole local history onto the remote head on a TEMPORARY
532
+ * worktree — never on the customer's branch. Whatever happens, their
533
+ * local refs and working tree are untouched.
534
+ * 2. Require the replayed tip's tree to equal the local tip's tree, ignoring
535
+ * files the workspace seeded that the local repo does not have. Rebasing
536
+ * linearises merges and `-X theirs` silently picks a side, so a
537
+ * hand-resolved merge conflict can otherwise ship the wrong content with
538
+ * exit 0 (13.2 risk 4).
539
+ * 3. If it differs, add ONE commit that sets the tree to the local tip's
540
+ * tree, keeping the seeded files and the workspace's `quonfig.json`. If
541
+ * that cannot be done cleanly, abort with nothing pushed.
542
+ * 4. Validate the tree that is ACTUALLY about to be pushed (unless
543
+ * `validate: false`), so content the workspace would reject fails here
544
+ * instead of at the server hook.
545
+ * 5. Plain `git push` of the result to `main`.
546
+ */
547
+ export const rebaseOntoOriginAndPush = async (dir, opts) => {
548
+ const localHead = await revParse(dir, 'HEAD');
549
+ if (localHead === null)
550
+ throw new Error('The local repository has no commits to push.');
551
+ const remoteHead = await revParse(dir, 'origin/main');
552
+ if (remoteHead === null) {
553
+ // Provisioning always creates `main` with auto_init, and the pre-receive
554
+ // hook rejects a zero-oid create (plan 5.6 item 1), so pushing the branch
555
+ // into existence is a dead end, not a fallback.
556
+ throw new Error('The workspace repository is not provisioned (it has no `main` branch).\nFinish creating the workspace in the Quonfig app, then run bootstrap again.');
557
+ }
558
+ const remotePaths = await treePaths(dir, remoteHead);
559
+ const localPaths = new Set(await treePaths(dir, localHead));
560
+ const seededOnly = new Set(remotePaths.filter((p) => !localPaths.has(p)));
561
+ const remoteQuonfigJson = remotePaths.includes('quonfig.json')
562
+ ? await revParse(dir, `${remoteHead}:quonfig.json`)
563
+ : null;
564
+ const { tmpdir } = await import('node:os');
565
+ const { join } = await import('node:path');
566
+ const worktree = join(tmpdir(), `qfg-bootstrap-${process.pid}-${Date.now()}`);
567
+ // The rebase keeps each commit's AUTHOR; this only decides the committer
568
+ // (and the author of the reconcile commit, which is the CLI's own).
569
+ const identityEnv = await committerFallbackEnv(dir);
570
+ // Ctrl-C between `worktree add` and the `finally` would leave the temp
571
+ // worktree registered in the customer's repo; drop it synchronously.
572
+ const onInterrupt = () => {
573
+ try {
574
+ execFileSync('git', [...GIT_SAFE_ARGS, '-C', dir, 'worktree', 'remove', '--force', worktree], { stdio: 'ignore' });
575
+ }
576
+ catch {
577
+ /* best effort — `git worktree prune` also clears it later */
578
+ }
579
+ // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit
580
+ process.exit(130);
581
+ };
582
+ process.once('SIGINT', onInterrupt);
583
+ try {
584
+ await runGit(['-C', dir, 'worktree', 'add', '--detach', worktree, localHead]);
585
+ try {
586
+ await runGit([
587
+ '-C',
588
+ worktree,
589
+ ...GIT_UNCONFIGURED_ARGS,
590
+ 'rebase',
591
+ '--root',
592
+ '--onto',
593
+ remoteHead,
594
+ '-X',
595
+ 'theirs',
596
+ '--committer-date-is-author-date',
597
+ ], { env: identityEnv });
598
+ }
599
+ catch (error) {
600
+ try {
601
+ await runGit(['-C', worktree, 'rebase', '--abort']);
602
+ }
603
+ catch {
604
+ /* nothing to abort */
605
+ }
606
+ throw new Error([
607
+ 'Could not replay your history onto the workspace repository.',
608
+ 'Nothing was pushed and your local repository is unchanged.',
609
+ '',
610
+ 'To land the files you have now WITHOUT their history, run:',
611
+ ` qfg push --dir ${dir}`,
612
+ '',
613
+ String(error),
614
+ ].join('\n'));
615
+ }
616
+ let candidate = await revParse(worktree, 'HEAD');
617
+ if (candidate === null)
618
+ throw new Error('The rebase produced no commit. Nothing was pushed.');
619
+ let reconcileSubject = null;
620
+ let mismatch = await treeMismatch(dir, { candidate, local: localHead, remoteQuonfigJson, seededOnly });
621
+ if (hasMismatch(mismatch)) {
622
+ // Honest subject: when the ONLY thing being put back is the workspace's
623
+ // own pin file, nothing was "reconciled" and saying so would be a lie.
624
+ reconcileSubject = mismatch.content.length > 0 ? RECONCILE_SUBJECT : PIN_SUBJECT;
625
+ const body = reconcileSubject === RECONCILE_SUBJECT
626
+ ? 'Sets the tree to the state of the local repository, keeping the files this workspace already had. Replaying merge commits one by one can otherwise resolve a conflict differently than you did.'
627
+ : 'The hosted workspace seeds its own quonfig.json, which carries the workspace pin and its environment list, so it wins over the one in the local repository.';
628
+ const keepFromRemote = [...seededOnly];
629
+ if (remoteQuonfigJson !== null && !seededOnly.has('quonfig.json'))
630
+ keepFromRemote.push('quonfig.json');
631
+ try {
632
+ await runGit(['-C', worktree, 'restore', `--source=${localHead}`, '--staged', '--worktree', '--', '.']);
633
+ if (keepFromRemote.length > 0) {
634
+ await runGit([
635
+ '-C',
636
+ worktree,
637
+ 'restore',
638
+ `--source=${remoteHead}`,
639
+ '--staged',
640
+ '--worktree',
641
+ '--',
642
+ ...keepFromRemote,
643
+ ]);
644
+ }
645
+ await runGit(['-C', worktree, ...GIT_UNCONFIGURED_ARGS, 'commit', '--no-verify', '-m', reconcileSubject, '-m', body], { env: identityEnv });
646
+ }
647
+ catch (error) {
648
+ throw new Error(`Could not reconcile your local content with the workspace repository: ${String(error)}\nNothing was pushed and your local repository is unchanged.`);
649
+ }
650
+ candidate = await revParse(worktree, 'HEAD');
651
+ if (candidate === null)
652
+ throw new Error('The reconcile commit produced no commit. Nothing was pushed.');
653
+ mismatch = await treeMismatch(dir, { candidate, local: localHead, remoteQuonfigJson, seededOnly });
654
+ if (hasMismatch(mismatch)) {
655
+ throw new Error(`Refusing to push: the result would not match your local files (${describeMismatch(mismatch)}).\nNothing was pushed and your local repository is unchanged.`);
656
+ }
657
+ }
658
+ // Validate what is ACTUALLY going to be pushed. The worktree holds exactly
659
+ // the candidate's tree, which is the local content plus the workspace's own
660
+ // quonfig.json — so an environment that exists only in the local pin file
661
+ // fails HERE, with a readable message, instead of at the server hook.
662
+ if (opts?.validate !== false)
663
+ await requireWorkspaceValidates(worktree);
664
+ await runGit(['-C', dir, 'push', 'origin', `${candidate}:refs/heads/main`]);
665
+ return { commitsRebased: await countCommits(dir, localHead), pushedSha: candidate, reconcileSubject };
666
+ }
667
+ finally {
668
+ process.off('SIGINT', onInterrupt);
669
+ await removeWorktree(dir, worktree);
670
+ }
373
671
  };
374
672
  export const hasAtLeastOneCommit = async (dir) => {
375
673
  try {
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Ajv errors -> readable `{ path, message }` violations (qfg-q5f6.1).
3
+ *
4
+ * SELF-CONTAINED ON PURPOSE. This file has no imports: it is copied verbatim
5
+ * into `cli/src/verify/` so `qfg verify`, the app server and the browser
6
+ * (list badge, view page) word every violation the same way. Change it here
7
+ * first and re-copy; do not let the two drift.
8
+ *
9
+ * It works on any error object shaped like Ajv's `ErrorObject` (keyword,
10
+ * instancePath, schemaPath, params, message), from Ajv run with
11
+ * `allErrors: true`.
12
+ *
13
+ * Tagged `oneOf` (plan 2026-09-25-advanced-schema-for-jev.md, W1): Ajv
14
+ * reports every branch's failures, which for a three-way union is mostly
15
+ * noise. When every branch is an object with the same `const` (or one-value
16
+ * `enum`) property, that property is the tag:
17
+ * - the value's tag picks a branch -> only that branch's errors are kept;
18
+ * - the tag is missing or unknown -> ONE error:
19
+ * "`type` must be one of noul, score, choice".
20
+ * A `oneOf` without a tag is left as Ajv reported it.
21
+ */
22
+ /** The subset of Ajv's `ErrorObject` this module reads. */
23
+ export interface SchemaErrorLike {
24
+ keyword: string;
25
+ instancePath: string;
26
+ schemaPath: string;
27
+ params: object;
28
+ message?: string;
29
+ propertyName?: string;
30
+ }
31
+ export interface SchemaViolation {
32
+ /** Dotted path inside the value (`criteria[1]`, `a.b`); "" for the root. */
33
+ path: string;
34
+ message: string;
35
+ }
36
+ /** Keyword of the single error that replaces a missing/unknown-tag `oneOf`. */
37
+ export declare const ONE_OF_TAG_KEYWORD = "oneOfTag";
38
+ /**
39
+ * Ajv `instancePath` (JSON pointer) -> dotted path:
40
+ * `/questions/tone/criteria/1` -> `questions.tone.criteria[1]`.
41
+ * Walks the value so only real array indexes become `[n]`.
42
+ */
43
+ export declare function instancePathToDotted(instancePath: string, value: unknown): string;
44
+ export interface OneOfTag {
45
+ /** The tag property name, e.g. `type`. */
46
+ name: string;
47
+ /** Tag value of each branch, in branch order. */
48
+ values: unknown[];
49
+ }
50
+ /**
51
+ * Find the tag of a `oneOf`: the ONE property that is a `const` (or a
52
+ * one-value `enum`) in every branch, with distinct values. Undefined when
53
+ * there is none, or more than one candidate.
54
+ */
55
+ export declare function findOneOfTag(branches: unknown[], root: unknown): OneOfTag | undefined;
56
+ /**
57
+ * Collapse tagged `oneOf` noise. Returns a new list in Ajv's order: errors
58
+ * from non-selected branches are dropped, and a missing/unknown tag becomes
59
+ * a single `oneOfTag` error at the union's position.
60
+ */
61
+ export declare function reshapeOneOfErrors<E extends SchemaErrorLike>(errors: readonly E[], rootSchema: unknown, value: unknown): (E | SchemaErrorLike)[];
62
+ /** One error -> message. Undefined means "skip it, a nested error says why". */
63
+ export declare function describeSchemaError(error: SchemaErrorLike): string | undefined;
64
+ /**
65
+ * Ajv errors for `value` against `rootSchema` -> violations, with tagged
66
+ * `oneOf` noise collapsed. Call it only when validation failed: it never
67
+ * returns an empty list.
68
+ */
69
+ export declare function schemaErrorsToViolations(errors: readonly SchemaErrorLike[], rootSchema: unknown, value: unknown): SchemaViolation[];