@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
|
@@ -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.
|
|
91
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
129
|
-
//
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
*/
|
package/dist/commands/push.js
CHANGED
|
@@ -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
|
},
|
package/dist/commands/verify.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
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: '
|
|
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
|
-
|
|
37
|
-
//
|
|
38
|
-
|
|
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 =
|
|
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
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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}"
|
|
179
|
-
|
|
180
|
-
|
|
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
|
};
|