@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.
- package/README.md +51 -51
- package/dist/codegen/json-schema-to-zod.js +127 -22
- package/dist/codegen/language-mappers/zod-base-mapper.d.ts +11 -1
- package/dist/codegen/language-mappers/zod-base-mapper.js +14 -2
- package/dist/codegen/language-mappers/zod-to-string-mapper.d.ts +2 -1
- package/dist/codegen/language-mappers/zod-to-string-mapper.js +6 -2
- package/dist/codegen/language-mappers/zod-to-typescript-mapper.d.ts +6 -2
- package/dist/codegen/language-mappers/zod-to-typescript-mapper.js +34 -5
- package/dist/codegen/language-mappers/zod-to-typescript-return-value-mapper.d.ts +6 -3
- package/dist/codegen/language-mappers/zod-to-typescript-return-value-mapper.js +22 -11
- package/dist/codegen/zod-introspection.d.ts +12 -0
- package/dist/codegen/zod-introspection.js +15 -0
- package/dist/commands/push.js +1 -1
- package/dist/commands/verify.js +1 -1
- package/dist/commands/workspace/bootstrap.d.ts +0 -8
- package/dist/commands/workspace/bootstrap.js +88 -96
- package/dist/migrate/push-to-cloud.js +1 -1
- package/dist/util/bootstrap-target.d.ts +40 -0
- package/dist/util/bootstrap-target.js +46 -0
- package/dist/util/clone-and-stack-push.d.ts +1 -4
- package/dist/util/clone-and-stack-push.js +2 -5
- package/dist/util/git-ops.d.ts +60 -2
- package/dist/util/git-ops.js +303 -5
- package/dist/verify/schema-error-reshape.d.ts +69 -0
- package/dist/verify/schema-error-reshape.js +257 -0
- package/dist/verify/schema-validator.d.ts +29 -0
- package/dist/verify/schema-validator.js +108 -0
- package/dist/verify/standalone.d.ts +31 -0
- package/dist/verify/standalone.js +156 -11
- package/dist/verify/validate.d.ts +16 -2
- package/dist/verify/validate.js +95 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +2 -2
- 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,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
|
|
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) {
|
package/dist/util/git-ops.d.ts
CHANGED
|
@@ -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
|
-
|
|
146
|
-
|
|
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.
|
package/dist/util/git-ops.js
CHANGED
|
@@ -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
|
-
|
|
369
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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[];
|