@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
@@ -1,5 +1,12 @@
1
1
  import * as introspect from '../zod-introspection.js';
2
2
  export class ZodBaseMapper {
3
+ /**
4
+ * Resolves the fixed tuple item at `index`. Mappers that emit value accessors override this to
5
+ * resolve the item at its indexed path rather than at the tuple's own path.
6
+ */
7
+ resolveTupleItem(item, _index) {
8
+ return this.resolveType(item);
9
+ }
3
10
  resolveType(type) {
4
11
  // Check for meta description first and allow implementers to handle it
5
12
  const metaDescription = introspect.getMetaDescription(type);
@@ -76,6 +83,10 @@ export class ZodBaseMapper {
76
83
  const internalType = this.resolveType(innerType);
77
84
  return this.optional(internalType);
78
85
  }
86
+ if (introspect.isIntersection(type)) {
87
+ const { left, right } = introspect.getIntersectionTypes(type);
88
+ return this.intersection(this.resolveType(left), this.resolveType(right));
89
+ }
79
90
  if (introspect.isRecord(type)) {
80
91
  const { keyType, valueType } = introspect.getRecordTypes(type);
81
92
  const keyTypeStr = this.resolveType(keyType);
@@ -87,8 +98,9 @@ export class ZodBaseMapper {
87
98
  }
88
99
  if (introspect.isTuple(type)) {
89
100
  const items = introspect.getTupleItems(type);
90
- const itemsStr = items.map((item) => this.resolveType(item));
91
- return this.tuple(itemsStr);
101
+ const itemsStr = items.map((item, index) => this.resolveTupleItem(item, index));
102
+ const rest = introspect.getTupleRest(type);
103
+ return this.tuple(itemsStr, rest ? this.resolveType(rest) : undefined);
92
104
  }
93
105
  if (introspect.isUndefined(type)) {
94
106
  return this.undefined();
@@ -9,6 +9,7 @@ export declare class ZodToStringMapper extends ZodBaseMapper {
9
9
  function(input: string, output: string): string;
10
10
  functionArguments(value?: $ZodFunctionArgs): string;
11
11
  functionReturns(value: $ZodFunctionOut): string;
12
+ intersection(left: string, right: string): string;
12
13
  literal(value: string | number | boolean | null): string;
13
14
  never(): string;
14
15
  null(): string;
@@ -18,7 +19,7 @@ export declare class ZodToStringMapper extends ZodBaseMapper {
18
19
  record(keyType: string, valueType: string): string;
19
20
  renderField(type: z.ZodTypeAny, key?: string): string;
20
21
  string(): string;
21
- tuple(wrappedTypes: string[]): string;
22
+ tuple(wrappedTypes: string[], rest?: string): string;
22
23
  undefined(): string;
23
24
  union(wrappedTypes: string[]): string;
24
25
  unknown(): string;
@@ -31,6 +31,9 @@ export class ZodToStringMapper extends ZodBaseMapper {
31
31
  const mapper = new ZodToStringMapper();
32
32
  return mapper.resolveType(value);
33
33
  }
34
+ intersection(left, right) {
35
+ return `z.intersection(${left}, ${right})`;
36
+ }
34
37
  literal(value) {
35
38
  return `z.literal(${JSON.stringify(value)})`;
36
39
  }
@@ -72,8 +75,9 @@ export class ZodToStringMapper extends ZodBaseMapper {
72
75
  string() {
73
76
  return 'z.string()';
74
77
  }
75
- tuple(wrappedTypes) {
76
- return `z.tuple([${wrappedTypes.join(', ')}])`;
78
+ tuple(wrappedTypes, rest) {
79
+ const base = `z.tuple([${wrappedTypes.join(', ')}])`;
80
+ return rest === undefined ? base : `${base}.rest(${rest})`;
77
81
  }
78
82
  undefined() {
79
83
  return 'z.undefined()';
@@ -1,9 +1,11 @@
1
1
  import { z } from 'zod';
2
+ import { $ZodType } from 'zod/v4/core';
2
3
  import { ZodTypeSupported } from '../types.js';
3
4
  import { ZodBaseMapper } from './zod-base-mapper.js';
4
5
  export type ZodToTypescriptMapperTarget = 'accessor' | 'raw';
5
6
  export declare class ZodToTypescriptMapper extends ZodBaseMapper {
6
7
  private fieldName;
8
+ private metaCaptureOpen;
7
9
  private metaDescription;
8
10
  private optionalProperty;
9
11
  private target;
@@ -18,6 +20,7 @@ export declare class ZodToTypescriptMapper extends ZodBaseMapper {
18
20
  function(args: string, returns: string): string;
19
21
  functionArguments(value?: z.ZodTuple): string;
20
22
  functionReturns(value: z.ZodTypeAny): string;
23
+ intersection(left: string, right: string): string;
21
24
  literal(value: string | number | boolean | null): string;
22
25
  never(): string;
23
26
  null(): string;
@@ -26,10 +29,11 @@ export declare class ZodToTypescriptMapper extends ZodBaseMapper {
26
29
  optional(wrappedType: string): string;
27
30
  record(keyType: string, valueType: string): string;
28
31
  renderField(type: ZodTypeSupported): string;
32
+ resolveType(type: $ZodType): string;
29
33
  string(): string;
30
- tuple(wrappedTypes: string[]): string;
34
+ tuple(wrappedTypes: string[], rest?: string): string;
31
35
  undefined(): string;
32
36
  union(wrappedTypes: string[]): string;
33
37
  unknown(): string;
34
- protected withMeta(description: string, resolveType: () => string): string;
38
+ protected withMeta(_description: string, resolveType: () => string): string;
35
39
  }
@@ -1,6 +1,10 @@
1
+ import * as introspect from '../zod-introspection.js';
1
2
  import { ZodBaseMapper } from './zod-base-mapper.js';
2
3
  export class ZodToTypescriptMapper extends ZodBaseMapper {
3
4
  fieldName;
5
+ // A field's comment comes from its own meta, looking through optional/nullable/default wrappers only.
6
+ // Meta deeper inside (a union branch title, an array item description) must not become the field's comment.
7
+ metaCaptureOpen = true;
4
8
  metaDescription = undefined;
5
9
  optionalProperty;
6
10
  target;
@@ -41,6 +45,9 @@ export class ZodToTypescriptMapper extends ZodBaseMapper {
41
45
  const mapper = new ZodToTypescriptMapper();
42
46
  return mapper.resolveType(value);
43
47
  }
48
+ intersection(left, right) {
49
+ return [left, right].map((t) => (t.includes(' | ') || t.includes('=>') ? `(${t})` : t)).join(' & ');
50
+ }
44
51
  literal(value) {
45
52
  return JSON.stringify(value);
46
53
  }
@@ -102,11 +109,34 @@ export class ZodToTypescriptMapper extends ZodBaseMapper {
102
109
  result += `"${this.fieldName}"${this.optionalProperty ? '?' : ''}: ${resolved}`;
103
110
  return result;
104
111
  }
112
+ resolveType(type) {
113
+ const wasOpen = this.metaCaptureOpen;
114
+ if (wasOpen) {
115
+ const description = introspect.getMetaDescription(type);
116
+ if (description) {
117
+ this.metaDescription = description;
118
+ }
119
+ }
120
+ this.metaCaptureOpen =
121
+ wasOpen && (introspect.isOptional(type) || introspect.isNullable(type) || introspect.isDefault(type));
122
+ try {
123
+ return super.resolveType(type);
124
+ }
125
+ finally {
126
+ this.metaCaptureOpen = wasOpen;
127
+ }
128
+ }
105
129
  string() {
106
130
  return 'string';
107
131
  }
108
- tuple(wrappedTypes) {
109
- return `[${wrappedTypes.join(', ')}]`;
132
+ tuple(wrappedTypes, rest) {
133
+ const items = [...wrappedTypes];
134
+ if (rest !== undefined) {
135
+ // `...A | B[]` would parse as `...(A | (B[]))`, so wrap anything that isn't a simple type
136
+ const restType = /^[\w ,<>]+$/.test(rest) ? rest : `(${rest})`;
137
+ items.push(`...${restType}[]`);
138
+ }
139
+ return `[${items.join(', ')}]`;
110
140
  }
111
141
  undefined() {
112
142
  return 'undefined';
@@ -125,9 +155,8 @@ export class ZodToTypescriptMapper extends ZodBaseMapper {
125
155
  unknown() {
126
156
  return 'unknown';
127
157
  }
128
- withMeta(description, resolveType) {
129
- // Store the description to be used when rendering the field
130
- this.metaDescription = description;
158
+ withMeta(_description, resolveType) {
159
+ // The description is captured in resolveType, which knows whether it belongs to this field
131
160
  return resolveType();
132
161
  }
133
162
  }
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { $ZodType } from 'zod/v4/core';
2
3
  import { ZodTypeSupported } from '../types.js';
3
4
  import { ZodBaseMapper } from './zod-base-mapper.js';
4
5
  export declare class ZodToTypescriptReturnValueMapper extends ZodBaseMapper {
@@ -6,9 +7,9 @@ export declare class ZodToTypescriptReturnValueMapper extends ZodBaseMapper {
6
7
  private FUNCTION_ARGUMENTS_NAME;
7
8
  private metaDescription;
8
9
  private returnTypePropertyPath;
9
- constructor({ fieldName, returnTypePropertyPath }?: {
10
+ constructor({ fieldName, returnTypePropertyPath, }?: {
10
11
  fieldName?: string;
11
- returnTypePropertyPath?: string[];
12
+ returnTypePropertyPath?: (number | string)[];
12
13
  });
13
14
  any(): string;
14
15
  array(): string;
@@ -17,6 +18,7 @@ export declare class ZodToTypescriptReturnValueMapper extends ZodBaseMapper {
17
18
  function(args: string, returns: string): string;
18
19
  functionArguments(value?: z.ZodTuple): string;
19
20
  functionReturns(): string;
21
+ intersection(): string;
20
22
  literal(): string;
21
23
  never(): string;
22
24
  null(): string;
@@ -25,8 +27,9 @@ export declare class ZodToTypescriptReturnValueMapper extends ZodBaseMapper {
25
27
  optional(wrappedType: string): string;
26
28
  record(): string;
27
29
  renderField(type: ZodTypeSupported): string;
30
+ protected resolveTupleItem(item: $ZodType, index: number): string;
28
31
  string(): string;
29
- tuple(wrappedTypes: string[]): string;
32
+ tuple(wrappedTypes: string[], rest?: string): string;
30
33
  undefined(): string;
31
34
  union(wrappedTypes: string[]): string;
32
35
  unknown(): string;
@@ -3,8 +3,9 @@ export class ZodToTypescriptReturnValueMapper extends ZodBaseMapper {
3
3
  fieldName;
4
4
  FUNCTION_ARGUMENTS_NAME = 'params';
5
5
  metaDescription = undefined;
6
+ // Strings are property names, numbers are tuple indexes
6
7
  returnTypePropertyPath;
7
- constructor({ fieldName, returnTypePropertyPath } = {}) {
8
+ constructor({ fieldName, returnTypePropertyPath, } = {}) {
8
9
  super();
9
10
  this.fieldName = fieldName;
10
11
  this.returnTypePropertyPath = returnTypePropertyPath ?? [];
@@ -36,6 +37,10 @@ export class ZodToTypescriptReturnValueMapper extends ZodBaseMapper {
36
37
  functionReturns() {
37
38
  return `Mustache.render(raw${this.printPropertyPath()} ?? "", ${this.FUNCTION_ARGUMENTS_NAME})`;
38
39
  }
40
+ intersection() {
41
+ // Explicitly not supporting navigation into intersections, pass the raw value through
42
+ return `raw${this.printPropertyPath()}`;
43
+ }
39
44
  literal() {
40
45
  return `raw${this.printPropertyPath()}`;
41
46
  }
@@ -102,18 +107,24 @@ export class ZodToTypescriptReturnValueMapper extends ZodBaseMapper {
102
107
  this.metaDescription = undefined;
103
108
  return result;
104
109
  }
110
+ resolveTupleItem(item, index) {
111
+ const mapper = new ZodToTypescriptReturnValueMapper({
112
+ returnTypePropertyPath: [...this.returnTypePropertyPath, index],
113
+ });
114
+ return mapper.resolveType(item);
115
+ }
105
116
  string() {
106
117
  return `raw${this.printPropertyPath()}`;
107
118
  }
108
- tuple(wrappedTypes) {
109
- const tupleNavigation = wrappedTypes.map((wt, index) => {
110
- let massagedWrappedType = wt;
111
- if (massagedWrappedType !== 'raw') {
112
- // Remove trailing ! from the wrapped type
113
- massagedWrappedType = massagedWrappedType.replace(/!$/, '');
114
- }
115
- return `${massagedWrappedType}?.[${index}]!`;
116
- });
119
+ tuple(wrappedTypes, rest) {
120
+ // Each fixed item was already resolved at its own index by resolveTupleItem
121
+ const tupleNavigation = [...wrappedTypes];
122
+ if (rest !== undefined) {
123
+ // Pass the remaining items through untouched; truncating to the fixed items would drop data at runtime.
124
+ // Mustache is never extracted inside arrays, so the rest needs no per-item mapping.
125
+ const path = this.printPropertyPath().replace(/!$/, '');
126
+ tupleNavigation.push(`...(raw${path}?.slice(${wrappedTypes.length}) ?? [])`);
127
+ }
117
128
  return `[${tupleNavigation.join(`, `)}]`;
118
129
  }
119
130
  undefined() {
@@ -140,7 +151,7 @@ export class ZodToTypescriptReturnValueMapper extends ZodBaseMapper {
140
151
  return '';
141
152
  }
142
153
  // Always safe navigate the path to ensure we don't throw an error if the property doesn't exist
143
- const path = paths.reduce((acc, part) => `${acc}?.['${part}']`, '');
154
+ const path = paths.reduce((acc, part) => (typeof part === 'number' ? `${acc}?.[${part}]` : `${acc}?.['${part}']`), '');
144
155
  // To satisfy TypeScript's type system, tell it the value is always defined
145
156
  return `${path}!`;
146
157
  }
@@ -12,6 +12,7 @@ export declare function isAny(schema: $ZodType): schema is z.ZodAny;
12
12
  export declare function isArray(schema: $ZodType): schema is z.ZodArray<z.ZodTypeAny>;
13
13
  export declare function isObject(schema: $ZodType): schema is z.ZodObject<z.ZodRawShape>;
14
14
  export declare function isTuple(schema: $ZodType): schema is z.ZodTuple;
15
+ export declare function isIntersection(schema: $ZodType): schema is z.ZodIntersection<z.ZodTypeAny, z.ZodTypeAny>;
15
16
  export declare function isRecord(schema: $ZodType): schema is z.ZodRecord;
16
17
  export declare function isEnum(schema: $ZodType): schema is z.ZodEnum<util.EnumLike>;
17
18
  export declare function isUnion(schema: $ZodType): schema is z.ZodUnion<[z.ZodTypeAny, ...z.ZodTypeAny[]]>;
@@ -31,6 +32,17 @@ export declare function getObjectShape(schema: z.ZodObject<z.ZodRawShape>): z.Zo
31
32
  * Gets the items from a tuple schema
32
33
  */
33
34
  export declare function getTupleItems(schema: z.ZodTuple): readonly $ZodType[];
35
+ /**
36
+ * Gets the rest element from a tuple schema (`z.tuple([...]).rest(x)`), or undefined when it has none
37
+ */
38
+ export declare function getTupleRest(schema: z.ZodTuple): $ZodType | undefined;
39
+ /**
40
+ * Gets both sides of an intersection schema
41
+ */
42
+ export declare function getIntersectionTypes(schema: z.ZodIntersection<z.ZodTypeAny, z.ZodTypeAny>): {
43
+ left: $ZodType;
44
+ right: $ZodType;
45
+ };
34
46
  /**
35
47
  * Gets the key and value types from a record schema
36
48
  */
@@ -38,6 +38,9 @@ export function isObject(schema) {
38
38
  export function isTuple(schema) {
39
39
  return schema instanceof z.ZodTuple;
40
40
  }
41
+ export function isIntersection(schema) {
42
+ return schema instanceof z.ZodIntersection;
43
+ }
41
44
  export function isRecord(schema) {
42
45
  return schema instanceof z.ZodRecord;
43
46
  }
@@ -83,6 +86,18 @@ export function getObjectShape(schema) {
83
86
  export function getTupleItems(schema) {
84
87
  return schema.def.items;
85
88
  }
89
+ /**
90
+ * Gets the rest element from a tuple schema (`z.tuple([...]).rest(x)`), or undefined when it has none
91
+ */
92
+ export function getTupleRest(schema) {
93
+ return schema.def.rest ?? undefined;
94
+ }
95
+ /**
96
+ * Gets both sides of an intersection schema
97
+ */
98
+ export function getIntersectionTypes(schema) {
99
+ return { left: schema.def.left, right: schema.def.right };
100
+ }
86
101
  /**
87
102
  * Gets the key and value types from a record schema
88
103
  */
@@ -334,7 +334,7 @@ export function buildRealDeps(cmd, orgSlug) {
334
334
  },
335
335
  async validate(dir) {
336
336
  const { validateWorkspace } = await import('../verify/validate.js');
337
- const result = validateWorkspace(dir);
337
+ const result = validateWorkspace(dir, { validateValues: true });
338
338
  const errors = result.issues.filter((i) => i.severity === 'error').map((i) => i.message);
339
339
  return { errors };
340
340
  },
@@ -23,7 +23,7 @@ export default class Verify extends BaseCommand {
23
23
  async run() {
24
24
  const { args, flags } = await this.parse(Verify);
25
25
  const dir = args.path;
26
- const result = validateWorkspace(dir);
26
+ const result = validateWorkspace(dir, { validateValues: true });
27
27
  if (flags.strict) {
28
28
  // Promote warnings to errors
29
29
  for (const issue of result.issues) {
@@ -9,12 +9,4 @@ 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;
19
- private remoteHasCommits;
20
12
  }
@@ -4,9 +4,16 @@ 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, tryParseWorkspacePin, writeWorkspaceSlug } from '../../util/quonfig-json.js';
7
+ import { bootstrapPinMismatch, describeBootstrapTarget } from '../../util/bootstrap-target.js';
8
+ import { readWorkspaceSlug } from '../../util/quonfig-json.js';
8
9
  import { resolveWorkspaceUuid } from '../../util/resolve-workspace.js';
9
- import { isGitRepo, hasAtLeastOneCommit, gitSetRemote, gitPushForceLease, gitPushForce, getRemoteUrl, displayUrl, runGit, } from '../../util/git-ops.js';
10
+ import { isGitRepo, hasAtLeastOneCommit, gitSetRemote, gitFetch, workspaceDocumentsAtRef, rebaseOntoOriginAndPush, getRemoteUrl, getOriginMainSha, displayUrl, } from '../../util/git-ops.js';
11
+ /**
12
+ * How the customer points their clone at the workspace after bootstrap.
13
+ * `pre-bootstrap` keeps their original history; the reset is safe because the
14
+ * pushed tree was proven equal to their tree before the push.
15
+ */
16
+ const LOCAL_RESET_RECIPE = 'git branch pre-bootstrap && git fetch origin && git reset --hard origin/main';
10
17
  export default class WorkspaceBootstrap extends BaseCommand {
11
18
  static description = "Push a local git repo to Gitea as this workspace's config repository";
12
19
  static examples = [
@@ -21,7 +28,7 @@ export default class WorkspaceBootstrap extends BaseCommand {
21
28
  }),
22
29
  force: Flags.boolean({
23
30
  default: false,
24
- description: 'Force push even if remote already has commits',
31
+ description: 'Accepted and ignored (no-op). Bootstrap never rewrites the workspace history.',
25
32
  }),
26
33
  'skip-validate': Flags.boolean({
27
34
  default: false,
@@ -33,13 +40,34 @@ export default class WorkspaceBootstrap extends BaseCommand {
33
40
  // Resolve target directory
34
41
  const dir = flags.dir || process.env.QUONFIG_DIR || process.cwd();
35
42
  const resolvedDir = path.resolve(dir);
36
- const { workspaceId, orgSlug } = await resolveWorkspaceUuid(this);
37
- // The display name is best-effort; we still want it for the confirmation
38
- // prompt. Fall back through saved profile → workspaceId UUID.
43
+ // `--force` is kept so pinned scripts don't die on an unknown flag, but a
44
+ // workspace's `main` is append-only: nothing here ever rewrites it.
45
+ if (flags.force) {
46
+ this.log('Note: --force is accepted for compatibility and ignored — bootstrap never rewrites the workspace.\n');
47
+ }
48
+ // The target is resolved like push/pull/sync resolve theirs: QUONFIG_WORKSPACE,
49
+ // then the directory's quonfig.json pin, then the active profile.
50
+ const { workspaceId, orgSlug } = await resolveWorkspaceUuid(this, undefined, resolvedDir);
51
+ // The pin is read again here for the prompt and for the mismatch guard
52
+ // below. A legacy bare-slug pin reads as "no pin", as it does for push.
53
+ let pin;
54
+ try {
55
+ pin = await readWorkspaceSlug(resolvedDir);
56
+ }
57
+ catch {
58
+ pin = undefined;
59
+ }
60
+ // The prompt names whichever source chose the target; the profile only
61
+ // when it IS the resolved workspace (qfg-8p8i).
39
62
  const authConfig = await loadAuthConfig();
40
63
  const activeProfile = getActiveProfile();
41
64
  const profile = authConfig?.profiles[activeProfile] || authConfig?.profiles[authConfig?.defaultProfile || 'default'];
42
- const workspaceName = profile?.workspaceSlug || profile?.workspaceName || workspaceId;
65
+ const workspaceName = describeBootstrapTarget({
66
+ envOverride: process.env.QUONFIG_WORKSPACE,
67
+ pin,
68
+ profile,
69
+ workspaceId,
70
+ });
43
71
  this.verboseLog('WorkspaceBootstrap', { workspaceId, orgSlug, dir: resolvedDir });
44
72
  // Validate: must be a git repo with at least one commit
45
73
  const isRepo = await isGitRepo(resolvedDir);
@@ -55,7 +83,7 @@ export default class WorkspaceBootstrap extends BaseCommand {
55
83
  this.log('Verifying config files...');
56
84
  try {
57
85
  const { validateWorkspace } = await import('../../verify/validate.js');
58
- const result = validateWorkspace(resolvedDir);
86
+ const result = validateWorkspace(resolvedDir, { validateValues: true });
59
87
  const errors = result.issues.filter((i) => i.severity === 'error');
60
88
  if (errors.length > 0) {
61
89
  this.log(`\nFound ${errors.length} validation error(s):\n`);
@@ -101,16 +129,11 @@ export default class WorkspaceBootstrap extends BaseCommand {
101
129
  }
102
130
  const { repoUrl, workspaceSlug: backendSlug } = tokenData;
103
131
  this.verboseLog('WorkspaceBootstrap', { repoUrl: displayUrl(repoUrl), backendSlug });
104
- // Idempotency: check if remote already has commits
105
- if (!flags.force) {
106
- const remoteHasCommits = await this.remoteHasCommits(repoUrl);
107
- if (remoteHasCommits) {
108
- this.log(`\nThe remote repository already has commits.`);
109
- this.log(`Run \`qfg pull --dir ${resolvedDir}\` to sync, then re-run bootstrap.`);
110
- this.log(`Or run with --force to overwrite the remote.\n`);
111
- return this.err('Remote is not empty.');
112
- }
113
- }
132
+ // Guard, as `qfg push` has: a directory pinned to one workspace is not
133
+ // pushed to another. Checked before the remote is touched.
134
+ const mismatch = bootstrapPinMismatch(pin, backendSlug);
135
+ if (mismatch)
136
+ return this.err(mismatch);
114
137
  // Set remote
115
138
  if (existingRemote) {
116
139
  this.log(`Updating remote origin...`);
@@ -119,96 +142,65 @@ export default class WorkspaceBootstrap extends BaseCommand {
119
142
  this.log(`Setting remote origin...`);
120
143
  }
121
144
  await gitSetRemote(resolvedDir, repoUrl);
122
- // Push
145
+ await gitFetch(resolvedDir);
146
+ // Every workspace repo is provisioned with `main`; no `main` means the
147
+ // workspace is half-created, and the pre-receive hook rejects creating it
148
+ // by push (plan 5.6 item 1).
149
+ if ((await getOriginMainSha(resolvedDir)) === undefined) {
150
+ return this.err('The workspace repository is not provisioned (it has no `main` branch).\nFinish creating the workspace in the Quonfig app, then run bootstrap again.');
151
+ }
152
+ // Bootstrap is for a FRESH workspace only. "Fresh" is "holds no documents",
153
+ // NOT "has no commits": provisioning seeds README.md and quonfig.json, so
154
+ // every real workspace arrives with two commits (plan
155
+ // 2026-09-17-tree-derived-cache.md 5.6, 13.2 risk 5).
156
+ const documents = await workspaceDocumentsAtRef(resolvedDir, 'origin/main');
157
+ if (documents.length > 0) {
158
+ const sample = documents.slice(0, 3).join(', ') + (documents.length > 3 ? ', ...' : '');
159
+ this.log(`\nThis workspace already holds ${documents.length} document(s): ${sample}`);
160
+ this.log(`Bootstrap lands your local history UNDER what is already there, so it is for fresh workspaces only.`);
161
+ this.log(`To send local changes to a workspace that is already in use, run:`);
162
+ this.log(` qfg push --dir ${resolvedDir}\n`);
163
+ return this.err('Workspace is not empty.');
164
+ }
165
+ // Push. The history is replayed onto the workspace's own head on a
166
+ // temporary worktree and pushed plainly: `main` is append-only, and a
167
+ // force-push silently wedges config delivery (plan 5.6).
123
168
  this.log('Pushing to Gitea...');
169
+ let pushResult;
124
170
  try {
125
- if (flags.force) {
126
- await gitPushForce(resolvedDir);
127
- }
128
- else {
129
- await gitPushForceLease(resolvedDir);
130
- }
171
+ // Validation runs again against the tree that is actually pushed (which
172
+ // carries the WORKSPACE's quonfig.json, not the local one), so content
173
+ // the server would reject fails here.
174
+ pushResult = await rebaseOntoOriginAndPush(resolvedDir, { validate: !flags['skip-validate'] });
131
175
  }
132
176
  catch (error) {
133
177
  return this.err(`Push failed: ${String(error)}`);
134
178
  }
135
- // Write the workspace pin into `quonfig.json` (Guard 1 in
136
- // project/plans/cli-git-sync.md). If the pin already exists and disagrees,
137
- // we keep what is there — bootstrap is for fresh cases, not re-pinning.
138
- // If we do write it, commit and push so the bootstrap leaves no
139
- // uncommitted changes behind.
140
- //
141
- // The pin is stored as `<org-slug>/<workspace-slug>`. The backend's
142
- // `workspaceSlug` is just the workspace component today; until the
143
- // server returns the slash form, we skip the write rather than
144
- // emitting a bare slug.
145
- const backendPin = tryParseWorkspacePin(backendSlug);
146
- try {
147
- const existingPin = await readWorkspaceSlug(resolvedDir);
148
- const existingFormatted = existingPin ? `${existingPin.orgSlug}/${existingPin.workspaceSlug}` : undefined;
149
- if (!backendPin) {
150
- this.verboseLog('WorkspaceBootstrap', `Backend workspaceSlug "${backendSlug}" is not in <org>/<ws> form; skipping pin write.`);
151
- }
152
- else if (existingPin &&
153
- (existingPin.orgSlug !== backendPin.orgSlug || existingPin.workspaceSlug !== backendPin.workspaceSlug)) {
154
- const backendFormatted = `${backendPin.orgSlug}/${backendPin.workspaceSlug}`;
155
- this.log('');
156
- this.log(`Warning: quonfig.json already pins workspace "${existingFormatted}", but the backend says this workspace is "${backendFormatted}".`);
157
- this.log('Leaving the existing pin in place. If this is wrong, edit quonfig.json manually and re-run.');
158
- }
159
- else if (existingPin) {
160
- this.verboseLog('WorkspaceBootstrap', `quonfig.json already pinned to ${existingFormatted}; no-op.`);
161
- }
162
- else {
163
- const backendFormatted = `${backendPin.orgSlug}/${backendPin.workspaceSlug}`;
164
- this.log('');
165
- this.log(`Pinning quonfig.json to workspace "${backendFormatted}"...`);
166
- await writeWorkspaceSlug(resolvedDir, backendPin);
167
- await this.commitAndPushPin(resolvedDir, backendFormatted, flags.force);
168
- }
169
- }
170
- catch (error) {
171
- // The main push has already succeeded, so don't fail the whole command.
172
- // Surface the issue clearly so the user knows to follow up.
173
- this.log('');
174
- this.log(`Warning: could not write the workspace pin to quonfig.json: ${String(error)}`);
175
- this.log('The workspace is connected, but you should re-run `qfg pull` to backfill the pin.');
179
+ this.log(`Landed ${pushResult.commitsRebased} commit(s) on the workspace's history.`);
180
+ if (pushResult.reconcileSubject !== null) {
181
+ this.log(`Added one commit ("${pushResult.reconcileSubject}") so the workspace tree matches your local files.`);
176
182
  }
177
183
  this.log(`\nBootstrap complete.`);
178
- this.log(`Workspace "${workspaceName}" is now connected to your local directory.`);
179
- this.log(`\nTo keep it in sync locally, run:`);
180
- this.log(` qfg sync --watch --dir ${resolvedDir}`);
184
+ this.log(`Workspace "${workspaceName}" now holds the history from ${resolvedDir}.`);
185
+ // The customer's branch is never touched, on success or failure, so it
186
+ // still points at the pre-bootstrap history — which shares no commit with
187
+ // what was just pushed (the replayed commits have new ids). `qfg pull` and
188
+ // `qfg sync` both refuse that state ("diverge" / STALE_HEAD), so the only
189
+ // honest advice is a reset onto the workspace.
190
+ //
191
+ // If we ever move the local branch ourselves (option A: save
192
+ // refs/quonfig/pre-bootstrap and fast-forward `main` to pushedSha once
193
+ // tree equality is proven), this block is what it replaces.
194
+ this.log(`\nYour local branch was not moved. The workspace has the same content under new commit ids.`);
195
+ this.log(`To point this clone at the workspace, keeping your old history on a branch:`);
196
+ this.log(` ${LOCAL_RESET_RECIPE}`);
181
197
  return {
198
+ commitsRebased: pushResult.commitsRebased,
182
199
  dir: resolvedDir,
200
+ localResetRecipe: LOCAL_RESET_RECIPE,
201
+ reconciled: pushResult.reconcileSubject !== null,
183
202
  repoUrl: displayUrl(repoUrl),
184
203
  workspaceId,
185
204
  };
186
205
  }
187
- /**
188
- * Stage, commit, and push the `quonfig.json` pin. Runs after the main
189
- * bootstrap push so the pin lands as a committed+pushed change rather than
190
- * an uncommitted local edit. We stage only `quonfig.json` (not `-A`) so we
191
- * don't accidentally sweep in unrelated user edits.
192
- */
193
- async commitAndPushPin(dir, slug, force) {
194
- // Stage just quonfig.json so we don't accidentally commit other dirty files.
195
- await runGit(['-C', dir, 'add', 'quonfig.json']);
196
- // If `git add` produced no staged change (e.g. content matched an earlier
197
- // version on disk), `git commit` would fail. Check the index first.
198
- const { stdout: diffStat } = await runGit(['-C', dir, 'diff', '--cached', '--name-only']);
199
- if (!diffStat.trim()) {
200
- this.verboseLog('WorkspaceBootstrap', 'quonfig.json pin matches HEAD; no commit needed.');
201
- return;
202
- }
203
- await runGit(['-C', dir, 'commit', '-m', `chore: pin quonfig.json to workspace "${slug}"`]);
204
- // Push the new commit using the same force semantics as the main push.
205
- const pushArgs = ['-C', dir, 'push', 'origin', 'main'];
206
- pushArgs.push(force ? '--force' : '--force-with-lease');
207
- await runGit(pushArgs);
208
- this.log(`Pushed pin commit.`);
209
- }
210
- async remoteHasCommits(repoUrl) {
211
- const { stdout } = await runGit(['ls-remote', '--heads', repoUrl]);
212
- return stdout.trim().length > 0;
213
- }
214
206
  }
@@ -47,7 +47,7 @@ const verifyOnDisk = (dir) => {
47
47
  // qfg-52qg: client-side verify against the cumulative tree before commit
48
48
  // lands. The Gitea pre-receive hook runs against the final HEAD; failing
49
49
  // here matches what the server would reject and avoids a wasted push.
50
- const verifyResult = validateWorkspace(dir);
50
+ const verifyResult = validateWorkspace(dir, { validateValues: true });
51
51
  if (!verifyResult.valid)
52
52
  throw new MigratorVerifyError(verifyResult);
53
53
  };