@quonfig/cli 0.0.15 → 0.0.16

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.
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Core logic for `qfg push`. Pure-ish, dependency-injected, so tests can drive
3
+ * it without hitting the network, touching git, or running validation.
4
+ *
5
+ * The oclif command (`src/commands/push.ts`) is a thin wrapper that fills in
6
+ * real implementations of every dependency and calls `runPush`.
7
+ *
8
+ * The three guards from `project/plans/cli-git-sync.md` are all enforced here:
9
+ * - Guards 1 + 2: `checkIdentity` cross-checks the requested target, repo
10
+ * pin, and git origin against the backend-resolved workspace.
11
+ * - Guard 3: `summarizeDiff` + `confirmYesNo`/`confirmTypedSlug` force the
12
+ * user to acknowledge destructive changes.
13
+ *
14
+ * Dispatch between the "clone path" (the local dir is a clone of the cloud
15
+ * repo) and the "bare path" (anything else — no .git, or a mismatched origin)
16
+ * is decided after identity passes.
17
+ *
18
+ * `--yes` skips the normal Y/N confirmation. It NEVER skips a typed-slug
19
+ * confirmation — that prompt is the destructive-change brake.
20
+ */
21
+ import * as fs from 'node:fs';
22
+ import * as path from 'node:path';
23
+ import { MIGRATOR_IDENTITY, cloneAndStackPush } from '../util/clone-and-stack-push.js';
24
+ import { readWorkspaceSlug, writeWorkspaceSlug } from '../util/quonfig-json.js';
25
+ import { checkIdentity } from './identity-check.js';
26
+ import { confirmTypedSlug, confirmYesNo } from './confirm.js';
27
+ import { summarizeDiff } from './diff-summary.js';
28
+ export class PushFatalError extends Error {
29
+ code;
30
+ constructor(message, code = 'PUSH_FATAL') {
31
+ super(message);
32
+ this.code = code;
33
+ this.name = 'PushFatalError';
34
+ }
35
+ }
36
+ /**
37
+ * Normalize a git remote URL for case-insensitive-host comparison. Mirrors
38
+ * the logic in `identity-check.ts` but we only need the boolean "do these
39
+ * point at the same repo" answer.
40
+ */
41
+ const normalizeForCompare = (url) => {
42
+ const stripped = url.replace(/^([a-z][\d+.a-z-]*:\/\/)[^/@]+@/i, '$1');
43
+ try {
44
+ const u = new URL(stripped);
45
+ const host = u.host.toLowerCase();
46
+ const pathname = u.pathname.replace(/\/+$/, '').replace(/\.git$/, '');
47
+ return `${u.protocol}//${host}${pathname}`;
48
+ }
49
+ catch {
50
+ return url.trim();
51
+ }
52
+ };
53
+ const sameRepo = (a, b) => normalizeForCompare(a) === normalizeForCompare(b);
54
+ const PUSHED_VIA_TRAILER = 'Pushed-Via: cli';
55
+ /** Append the Pushed-Via trailer to a commit message if it isn't already present. */
56
+ export const withPushedViaTrailer = (message) => {
57
+ if (message.includes(PUSHED_VIA_TRAILER))
58
+ return message;
59
+ const trimmed = message.replace(/\s+$/, '');
60
+ return `${trimmed}\n\n${PUSHED_VIA_TRAILER}\n`;
61
+ };
62
+ export async function runPush(input, deps) {
63
+ const log = deps.log ?? ((s) => console.log(s));
64
+ const errLog = deps.errLog ?? ((s) => console.error(s));
65
+ if (!input.requestedTarget || input.requestedTarget.trim().length === 0) {
66
+ throw new PushFatalError('No workspace target set. Pass --workspace or run `qfg login` to set the active profile workspace.', 'NO_TARGET');
67
+ }
68
+ if (!fs.existsSync(input.dir)) {
69
+ throw new PushFatalError(`Directory does not exist: ${input.dir}`, 'NO_DIR');
70
+ }
71
+ // Guard 1 pre-work: read repo pin + current origin BEFORE we mint a write
72
+ // token. The identity check happens after minting (so we can cross-reference
73
+ // the backend's canonical identity) but we want the pin/origin up front.
74
+ const repoPinSlug = await readWorkspaceSlug(input.dir);
75
+ const hasGit = await deps.gitOps.isGitRepo(input.dir);
76
+ const remoteOriginUrl = hasGit ? await deps.gitOps.getRemoteOriginUrl(input.dir) : undefined;
77
+ // Mint the write token. The backend accepts a slug OR a UUID for workspaceId
78
+ // and resolves to the same row — we trust whatever `requestedTarget` is.
79
+ const backend = await deps.mintWriteToken(input.requestedTarget);
80
+ // Guards 1 + 2: cross-check the three identity signals.
81
+ const identity = checkIdentity({
82
+ requestedTarget: input.requestedTarget,
83
+ repoPinSlug,
84
+ remoteOriginUrl,
85
+ backend: {
86
+ workspaceSlug: backend.workspaceSlug,
87
+ workspaceId: backend.workspaceId,
88
+ repoUrl: backend.repoUrl,
89
+ },
90
+ });
91
+ if (identity.kind === 'abort') {
92
+ errLog(`Identity check failed: ${identity.reason}`);
93
+ for (const [k, v] of Object.entries(identity.details)) {
94
+ errLog(` ${k}: ${v}`);
95
+ }
96
+ errLog('Refusing to push. --yes does not override this.');
97
+ throw new PushFatalError(`identity check: ${identity.reason}`, 'IDENTITY_ABORT');
98
+ }
99
+ const requiresTypedSlug = identity.kind === 'requires-typed-slug-confirmation';
100
+ // Guard: validation. Skipped with --skip-validate.
101
+ if (!input.skipValidate) {
102
+ log('Verifying config files...');
103
+ const result = await deps.validate(input.dir);
104
+ if (result.errors.length > 0) {
105
+ errLog(`Found ${result.errors.length} validation error(s):`);
106
+ for (const msg of result.errors)
107
+ errLog(` ${msg}`);
108
+ throw new PushFatalError('validation failed; pass --skip-validate to bypass', 'VALIDATION_FAILED');
109
+ }
110
+ log('Validation passed.');
111
+ }
112
+ // Dispatch: clone path vs bare path.
113
+ //
114
+ // Clone path: the dir has a `.git/` AND its origin normalizes to the
115
+ // backend.repoUrl. We push HEAD directly.
116
+ //
117
+ // Bare path: no `.git/`, OR the origin points elsewhere. We clone the cloud
118
+ // repo into a scratch dir inside `.quonfig-push-clone/` and stack a commit
119
+ // that mirrors the local dir's contents on top.
120
+ const isClonePath = hasGit && remoteOriginUrl !== undefined && sameRepo(remoteOriginUrl, backend.repoUrl);
121
+ if (isClonePath) {
122
+ return doClonePath(input, deps, backend, requiresTypedSlug, repoPinSlug === undefined, log);
123
+ }
124
+ return doBarePath(input, deps, backend, requiresTypedSlug, repoPinSlug === undefined, log);
125
+ }
126
+ async function doClonePath(input, deps, backend, requiresTypedSlug, unpinned, log) {
127
+ // Point origin at the authenticated URL so `git fetch` / `git push` work
128
+ // without touching global credential helpers.
129
+ await deps.gitOps.setRemoteOrigin(input.dir, backend.repoUrl);
130
+ log('Fetching from remote...');
131
+ await deps.gitOps.fetch(input.dir);
132
+ const deltas = await deps.gitOps.diffHeadVsOrigin(input.dir);
133
+ const totalFilesInRemote = await deps.gitOps.countFilesInRemote(input.dir);
134
+ const summary = summarizeDiff(deltas, { totalFilesInRemote, unpinned });
135
+ log('');
136
+ log(summary.renderText({
137
+ workspaceSlug: backend.workspaceSlug,
138
+ repoUrl: stripAuth(backend.repoUrl),
139
+ branch: 'main',
140
+ localDir: input.dir,
141
+ }));
142
+ log('');
143
+ // Short-circuit: literally nothing to push.
144
+ if (summary.totals.filesTouched === 0) {
145
+ return { kind: 'no-op', reason: 'Local tree matches remote HEAD. Nothing to push.' };
146
+ }
147
+ const ok = await decideConfirm({
148
+ destructive: summary.isDestructive,
149
+ requiresTypedSlug,
150
+ yes: input.yes,
151
+ workspaceSlug: backend.workspaceSlug,
152
+ confirmIO: deps.confirmIO,
153
+ });
154
+ if (!ok) {
155
+ log('Aborted, nothing pushed.');
156
+ return { kind: 'aborted', reason: 'user declined at confirm prompt' };
157
+ }
158
+ log('Pushing to origin/main...');
159
+ try {
160
+ await deps.gitOps.push(input.dir);
161
+ }
162
+ catch (error) {
163
+ const msg = error instanceof Error ? error.message : String(error);
164
+ if (/non-fast-forward|fetch first|rejected/i.test(msg)) {
165
+ throw new PushFatalError('Remote has changes not in local — run `qfg pull` first, then re-run `qfg push`.', 'NON_FAST_FORWARD');
166
+ }
167
+ throw new PushFatalError(`Push failed: ${msg}`, 'PUSH_FAILED');
168
+ }
169
+ // Offer to pin the workspace slug in quonfig.json if missing. Non-fatal.
170
+ if (unpinned && !input.noPinWrite) {
171
+ try {
172
+ await writeWorkspaceSlug(input.dir, backend.workspaceSlug);
173
+ log(`Wrote workspace = "${backend.workspaceSlug}" into quonfig.json`);
174
+ }
175
+ catch {
176
+ // non-fatal; pin is advisory
177
+ }
178
+ }
179
+ return { kind: 'pushed', dispatchedAs: 'clone-path' };
180
+ }
181
+ async function doBarePath(input, deps, backend, requiresTypedSlug, unpinned, log) {
182
+ // Scratch clone location: sibling `.quonfig-push-clone-<timestamp>` so we
183
+ // don't clobber or re-use anything from a previous run.
184
+ const scratchDir = path.join(path.dirname(path.resolve(input.dir)), `.quonfig-push-clone-${Date.now()}`);
185
+ // Local author for the migrated commit. Prefer the local git config in
186
+ // `input.dir` so attribution matches what the user has set. If it is not
187
+ // available, fall back to the migrator identity but tell the user — the plan
188
+ // requires this be loud.
189
+ let author = MIGRATOR_IDENTITY;
190
+ try {
191
+ const local = await deps.gitOps.getLocalAuthor(input.dir);
192
+ if (local && local.name && local.email) {
193
+ author = local;
194
+ }
195
+ else {
196
+ log(`Warning: no local git user.name/user.email in ${input.dir}; commits will be attributed to ${MIGRATOR_IDENTITY.name} <${MIGRATOR_IDENTITY.email}>.`);
197
+ }
198
+ }
199
+ catch {
200
+ log(`Warning: could not read local git user identity; commits will be attributed to ${MIGRATOR_IDENTITY.name} <${MIGRATOR_IDENTITY.email}>.`);
201
+ }
202
+ // We need the deltas for the Guard 3 summary BEFORE we decide to commit and
203
+ // push. To produce them we need the scratch clone in hand (so we can compare
204
+ // file-by-file). We clone into the scratch dir, take the diff, show the
205
+ // summary, confirm, then apply the delta via `cloneAndStackPush`.
206
+ //
207
+ // Rather than re-implement cloneAndStackPush's clone step here, we let it
208
+ // own the clone-commit-push pipeline and pass a deltas callback that runs
209
+ // AFTER the clone (it receives the localDir/scratchDir) but BEFORE the
210
+ // commit. `cloneAndStackPush` only exposes an `applyDelta` hook (write files
211
+ // then return), so we split the flow: first a probe clone to take the
212
+ // diff, then the real clone-and-stack-push. This is simpler than rewiring
213
+ // cloneAndStackPush's API.
214
+ // Probe clone.
215
+ await deps.gitOps.fetch(input.dir) // no-op for bare path if not a repo; deps.gitOps may short-circuit
216
+ .catch(() => {
217
+ // bare-path doesn't require a local fetch — swallow the error
218
+ });
219
+ // We don't have a probe clone helper; instead, ask the deps for a bare-path
220
+ // delta computation. The test double will produce this from a fixture; the
221
+ // real implementation walks both trees after ensuring a scratch clone exists.
222
+ // This is provided by the command glue layer.
223
+ const deltas = await deps.gitOps.diffHeadVsOrigin(input.dir);
224
+ const totalFilesInRemote = await deps.gitOps.countFilesInRemote(input.dir);
225
+ const summary = summarizeDiff(deltas, { totalFilesInRemote, unpinned });
226
+ log('');
227
+ log(summary.renderText({
228
+ workspaceSlug: backend.workspaceSlug,
229
+ repoUrl: stripAuth(backend.repoUrl),
230
+ branch: 'main',
231
+ localDir: input.dir,
232
+ }));
233
+ log('');
234
+ if (summary.totals.filesTouched === 0) {
235
+ return { kind: 'no-op', reason: 'Local tree matches remote HEAD. Nothing to push.' };
236
+ }
237
+ const ok = await decideConfirm({
238
+ destructive: summary.isDestructive,
239
+ requiresTypedSlug,
240
+ yes: input.yes,
241
+ workspaceSlug: backend.workspaceSlug,
242
+ confirmIO: deps.confirmIO,
243
+ });
244
+ if (!ok) {
245
+ log('Aborted, nothing pushed.');
246
+ return { kind: 'aborted', reason: 'user declined at confirm prompt' };
247
+ }
248
+ const baseMessage = input.message ?? `qfg push: ${summary.totals.filesTouched} file change(s)`;
249
+ const commitMessage = withPushedViaTrailer(baseMessage);
250
+ log('Pushing via clone-and-stack...');
251
+ try {
252
+ const result = await cloneAndStackPush({
253
+ remoteUrl: backend.repoUrl,
254
+ localDir: scratchDir,
255
+ author,
256
+ commitMessage,
257
+ async applyDelta(clonedDir) {
258
+ // The caller promised deps.copyDirMirror copies `input.dir` into
259
+ // `clonedDir`, skipping `.git/`. cloneAndStackPush will `git add --all`
260
+ // after this returns.
261
+ await deps.copyDirMirror(input.dir, clonedDir);
262
+ },
263
+ });
264
+ if (unpinned && !input.noPinWrite) {
265
+ try {
266
+ await writeWorkspaceSlug(input.dir, backend.workspaceSlug);
267
+ log(`Wrote workspace = "${backend.workspaceSlug}" into quonfig.json`);
268
+ }
269
+ catch {
270
+ // non-fatal
271
+ }
272
+ }
273
+ return { kind: 'pushed', dispatchedAs: 'bare-path', commitSha: result.commitSha };
274
+ }
275
+ catch (error) {
276
+ const msg = error instanceof Error ? error.message : String(error);
277
+ if (/non-fast-forward|fetch first|rejected/i.test(msg)) {
278
+ throw new PushFatalError('Remote has changes not in local — run `qfg pull` first, then re-run `qfg push`.', 'NON_FAST_FORWARD');
279
+ }
280
+ throw new PushFatalError(`Push failed: ${msg}`, 'PUSH_FAILED');
281
+ }
282
+ finally {
283
+ // Best-effort cleanup of the scratch clone. Ignore failures so a cleanup
284
+ // problem doesn't mask a real error.
285
+ try {
286
+ fs.rmSync(scratchDir, { recursive: true, force: true });
287
+ }
288
+ catch {
289
+ /* ignore */
290
+ }
291
+ }
292
+ }
293
+ /**
294
+ * Map the guard outputs to the right prompt:
295
+ * - Typed-slug (always, never skipped by --yes) when identity demanded it
296
+ * OR when the diff is destructive.
297
+ * - Standard Y/N otherwise, unless --yes is set.
298
+ */
299
+ async function decideConfirm(args) {
300
+ const needsTyped = args.requiresTypedSlug || args.destructive;
301
+ if (needsTyped) {
302
+ return confirmTypedSlug(args.workspaceSlug, `Type the workspace slug "${args.workspaceSlug}" to confirm: `, args.confirmIO ?? {});
303
+ }
304
+ if (args.yes)
305
+ return true;
306
+ return confirmYesNo('Proceed? [y/N] ', args.confirmIO ?? {});
307
+ }
308
+ const stripAuth = (url) => {
309
+ try {
310
+ const u = new URL(url);
311
+ u.username = '';
312
+ u.password = '';
313
+ return u.toString();
314
+ }
315
+ catch {
316
+ return url;
317
+ }
318
+ };
@@ -3,8 +3,22 @@ export interface GiteaTokenResponse {
3
3
  expiresAt: string | null;
4
4
  repoUrl: string;
5
5
  token: string;
6
+ /**
7
+ * Canonical workspace UUID for the minted token. Used as the backend
8
+ * identity in the push identity check — required so the CLI can match a
9
+ * `--workspace <UUID>` request against the canonical workspace row without
10
+ * assuming the slug is also the UUID. Backend returns this alongside
11
+ * `workspaceSlug` as of the Guard 1 rollout in `project/plans/cli-git-sync.md`.
12
+ */
13
+ workspaceId: string;
14
+ /**
15
+ * Human-readable workspace slug (NOT the UUID). Used to pin `quonfig.json`
16
+ * and for identity-check guard messages. Required as of the Guard 1 rollout
17
+ * in `project/plans/cli-git-sync.md`; the backend always returns it.
18
+ */
19
+ workspaceSlug: string;
6
20
  }
7
- export declare const mintGiteaToken: (workspaceId: string, scope: "read" | "write", purpose: "pull" | "bootstrap") => Promise<GiteaTokenResponse>;
21
+ export declare const mintGiteaToken: (workspaceId: string, scope: "read" | "write", purpose: "pull" | "bootstrap" | "push") => Promise<GiteaTokenResponse>;
8
22
  /**
9
23
  * Mint a Gitea read token, store it, and return the entry.
10
24
  */
@@ -41,6 +41,7 @@ export const mintAndStoreGiteaReadToken = async (workspaceId) => {
41
41
  token: data.token,
42
42
  repoUrl: data.repoUrl,
43
43
  expiresAt: data.expiresAt,
44
+ workspaceSlug: data.workspaceSlug,
44
45
  };
45
46
  await saveGiteaToken(workspaceId, entry);
46
47
  return entry;
@@ -2,6 +2,13 @@ export interface GiteaTokenEntry {
2
2
  expiresAt: string | null;
3
3
  repoUrl: string;
4
4
  token: string;
5
+ /**
6
+ * Human-readable workspace slug (NOT the UUID). Populated from the
7
+ * `gitea.token` response. Optional to stay backward-compatible with older
8
+ * entries written before the field was added; callers must handle the
9
+ * `undefined` case by re-minting.
10
+ */
11
+ workspaceSlug?: string;
5
12
  }
6
13
  export declare const loadGiteaToken: (workspaceId: string) => Promise<GiteaTokenEntry | null>;
7
14
  export declare const saveGiteaToken: (workspaceId: string, entry: GiteaTokenEntry) => Promise<void>;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Reader/writer for the workspace's `quonfig.json` file.
3
+ *
4
+ * Today the file is `{ environments: string[] }`. We are adding an optional
5
+ * `workspace: "<slug>"` pin (Guard 1 in `project/plans/cli-git-sync.md`)
6
+ * so commands like `qfg push` can confirm that the local dir belongs to the
7
+ * cloud workspace they think it does.
8
+ *
9
+ * The reader is permissive: missing file or missing field returns `undefined`
10
+ * (no throw). It only throws when the file exists but cannot be parsed.
11
+ *
12
+ * The writer preserves all unknown fields, sticks to 2-space indent, and
13
+ * always ends with a trailing newline — matching how `qfg init` and the
14
+ * migrate flow create the file today.
15
+ */
16
+ /**
17
+ * Shape of `quonfig.json`. Everything is optional from the reader's
18
+ * perspective so a partially-written or in-flight file doesn't blow up
19
+ * downstream consumers — `qfg verify` is the source of strict validation.
20
+ */
21
+ export interface QuonfigJson {
22
+ [key: string]: unknown;
23
+ environments?: string[];
24
+ /** Workspace slug (human-readable, not the UUID). Used as the repo pin. */
25
+ workspace?: string;
26
+ }
27
+ /**
28
+ * Read the workspace slug pin from `<dir>/quonfig.json`.
29
+ *
30
+ * Returns `undefined` when the file is missing OR when the `workspace`
31
+ * field is absent OR when the field is present but not a string.
32
+ * Throws only when the file exists but contains malformed JSON, since
33
+ * that is a real bug a caller should surface.
34
+ */
35
+ export declare function readWorkspaceSlug(dir: string): Promise<string | undefined>;
36
+ /**
37
+ * Write the workspace slug pin into `<dir>/quonfig.json`.
38
+ *
39
+ * If the file exists, all other fields are preserved and only `workspace`
40
+ * is set/overwritten. If the file is missing, a minimal `{workspace: slug}`
41
+ * file is created. Format is 2-space indent + trailing newline to match
42
+ * the file shape produced by `qfg init` and `qfg migrate`.
43
+ */
44
+ export declare function writeWorkspaceSlug(dir: string, slug: string): Promise<void>;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Reader/writer for the workspace's `quonfig.json` file.
3
+ *
4
+ * Today the file is `{ environments: string[] }`. We are adding an optional
5
+ * `workspace: "<slug>"` pin (Guard 1 in `project/plans/cli-git-sync.md`)
6
+ * so commands like `qfg push` can confirm that the local dir belongs to the
7
+ * cloud workspace they think it does.
8
+ *
9
+ * The reader is permissive: missing file or missing field returns `undefined`
10
+ * (no throw). It only throws when the file exists but cannot be parsed.
11
+ *
12
+ * The writer preserves all unknown fields, sticks to 2-space indent, and
13
+ * always ends with a trailing newline — matching how `qfg init` and the
14
+ * migrate flow create the file today.
15
+ */
16
+ import * as fs from 'node:fs';
17
+ import * as path from 'node:path';
18
+ const QUONFIG_JSON = 'quonfig.json';
19
+ async function readQuonfigJson(dir) {
20
+ const filePath = path.join(dir, QUONFIG_JSON);
21
+ let raw;
22
+ try {
23
+ raw = await fs.promises.readFile(filePath, 'utf8');
24
+ }
25
+ catch (error) {
26
+ if (error.code === 'ENOENT')
27
+ return undefined;
28
+ throw error;
29
+ }
30
+ // JSON.parse will throw on malformed JSON — that's the documented behavior.
31
+ const parsed = JSON.parse(raw);
32
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
33
+ throw new TypeError(`${QUONFIG_JSON} must be a JSON object`);
34
+ }
35
+ return parsed;
36
+ }
37
+ /**
38
+ * Read the workspace slug pin from `<dir>/quonfig.json`.
39
+ *
40
+ * Returns `undefined` when the file is missing OR when the `workspace`
41
+ * field is absent OR when the field is present but not a string.
42
+ * Throws only when the file exists but contains malformed JSON, since
43
+ * that is a real bug a caller should surface.
44
+ */
45
+ export async function readWorkspaceSlug(dir) {
46
+ const parsed = await readQuonfigJson(dir);
47
+ if (!parsed)
48
+ return undefined;
49
+ const slug = parsed.workspace;
50
+ return typeof slug === 'string' ? slug : undefined;
51
+ }
52
+ /**
53
+ * Write the workspace slug pin into `<dir>/quonfig.json`.
54
+ *
55
+ * If the file exists, all other fields are preserved and only `workspace`
56
+ * is set/overwritten. If the file is missing, a minimal `{workspace: slug}`
57
+ * file is created. Format is 2-space indent + trailing newline to match
58
+ * the file shape produced by `qfg init` and `qfg migrate`.
59
+ */
60
+ export async function writeWorkspaceSlug(dir, slug) {
61
+ const existing = (await readQuonfigJson(dir)) ?? {};
62
+ const next = { ...existing, workspace: slug };
63
+ const filePath = path.join(dir, QUONFIG_JSON);
64
+ await fs.promises.writeFile(filePath, JSON.stringify(next, null, 2) + '\n', 'utf8');
65
+ }
@@ -279,7 +279,9 @@ export function validateWorkspace(workspaceDir) {
279
279
  quonfigParsed = null;
280
280
  issues.push({ file: 'quonfig.json', message: `Invalid JSON: ${error.message}`, severity: 'error' });
281
281
  }
282
- const quonfigResult = z.object({ environments: z.array(z.string()) }).safeParse(quonfigParsed);
282
+ const quonfigResult = z
283
+ .object({ environments: z.array(z.string()), workspace: z.string().optional() })
284
+ .safeParse(quonfigParsed);
283
285
  if (quonfigResult.success) {
284
286
  for (const envId of quonfigResult.data.environments) {
285
287
  declaredEnvIds.add(envId);
@@ -606,7 +608,9 @@ export function validateFileMap(files) {
606
608
  issues.push({ file: 'quonfig.json', message: `Invalid JSON: ${error.message}`, severity: 'error' });
607
609
  }
608
610
  if (quonfigParsed !== null) {
609
- const quonfigResult = z.object({ environments: z.array(z.string()) }).safeParse(quonfigParsed);
611
+ const quonfigResult = z
612
+ .object({ environments: z.array(z.string()), workspace: z.string().optional() })
613
+ .safeParse(quonfigParsed);
610
614
  if (quonfigResult.success) {
611
615
  for (const envId of quonfigResult.data.environments) {
612
616
  declaredEnvIds.add(envId);
@@ -567,6 +567,14 @@
567
567
  "name": "samples",
568
568
  "allowNo": true,
569
569
  "type": "boolean"
570
+ },
571
+ "workspace": {
572
+ "description": "Workspace slug to pin in quonfig.json (Guard 1). If omitted, no pin is written.",
573
+ "name": "workspace",
574
+ "required": false,
575
+ "hasDynamicHelp": false,
576
+ "multiple": false,
577
+ "type": "option"
570
578
  }
571
579
  },
572
580
  "hasDynamicHelp": false,
@@ -1287,6 +1295,101 @@
1287
1295
  "pull.js"
1288
1296
  ]
1289
1297
  },
1298
+ "push": {
1299
+ "aliases": [],
1300
+ "args": {},
1301
+ "description": "Push local config changes up to your workspace on Quonfig cloud.\n\nEnforces three guards before touching the remote:\n 1. The dir's quonfig.json \"workspace\" pin must match the backend (if set).\n 2. The dir's git origin must match the backend repo URL (if set).\n 3. A diff summary is shown; destructive changes (10+ deletes, >=25% of\n files, or an unpinned dir) require typing the workspace slug to confirm.\n\n qfg push # uses QUONFIG_DIR + profile workspace\n qfg push --dir ./our-config\n qfg push --dir ./our-config --workspace acme-prod\n qfg push --dir ./our-config --yes # skip normal Y/N (never skips typed-slug)\n qfg push --dir ./our-config --skip-validate",
1302
+ "examples": [
1303
+ "<%= config.bin %> push --dir ./our-config",
1304
+ "<%= config.bin %> push --workspace acme-prod --dir ./our-config",
1305
+ "<%= config.bin %> push --dir ./our-config --yes"
1306
+ ],
1307
+ "flags": {
1308
+ "json": {
1309
+ "description": "Format output as json.",
1310
+ "helpGroup": "GLOBAL",
1311
+ "name": "json",
1312
+ "allowNo": false,
1313
+ "type": "boolean"
1314
+ },
1315
+ "interactive": {
1316
+ "description": "Force interactive mode",
1317
+ "helpGroup": "GLOBAL",
1318
+ "name": "interactive",
1319
+ "allowNo": true,
1320
+ "type": "boolean"
1321
+ },
1322
+ "no-color": {
1323
+ "description": "Do not colorize output",
1324
+ "env": "NO_COLOR",
1325
+ "helpGroup": "GLOBAL",
1326
+ "name": "no-color",
1327
+ "allowNo": false,
1328
+ "type": "boolean"
1329
+ },
1330
+ "verbose": {
1331
+ "description": "Verbose output",
1332
+ "helpGroup": "GLOBAL",
1333
+ "name": "verbose",
1334
+ "allowNo": false,
1335
+ "type": "boolean"
1336
+ },
1337
+ "dir": {
1338
+ "description": "Local directory to push (defaults to QUONFIG_DIR env var)",
1339
+ "name": "dir",
1340
+ "hasDynamicHelp": false,
1341
+ "multiple": false,
1342
+ "type": "option"
1343
+ },
1344
+ "workspace": {
1345
+ "description": "Workspace slug or UUID (defaults to active profile)",
1346
+ "name": "workspace",
1347
+ "hasDynamicHelp": false,
1348
+ "multiple": false,
1349
+ "type": "option"
1350
+ },
1351
+ "yes": {
1352
+ "description": "Skip the standard Y/N confirm. Never skips typed-slug prompts.",
1353
+ "name": "yes",
1354
+ "allowNo": false,
1355
+ "type": "boolean"
1356
+ },
1357
+ "skip-validate": {
1358
+ "description": "Skip `qfg validate` preflight",
1359
+ "name": "skip-validate",
1360
+ "allowNo": false,
1361
+ "type": "boolean"
1362
+ },
1363
+ "no-pin-write": {
1364
+ "description": "Do not offer to write workspace slug into quonfig.json on success",
1365
+ "name": "no-pin-write",
1366
+ "allowNo": false,
1367
+ "type": "boolean"
1368
+ },
1369
+ "message": {
1370
+ "char": "m",
1371
+ "description": "Commit message for bare-path pushes (ignored on clone-path; push what is already committed)",
1372
+ "name": "message",
1373
+ "hasDynamicHelp": false,
1374
+ "multiple": false,
1375
+ "type": "option"
1376
+ }
1377
+ },
1378
+ "hasDynamicHelp": false,
1379
+ "hiddenAliases": [],
1380
+ "id": "push",
1381
+ "pluginAlias": "@quonfig/cli",
1382
+ "pluginName": "@quonfig/cli",
1383
+ "pluginType": "core",
1384
+ "strict": true,
1385
+ "enableJsonFlag": true,
1386
+ "isESM": true,
1387
+ "relativePath": [
1388
+ "dist",
1389
+ "commands",
1390
+ "push.js"
1391
+ ]
1392
+ },
1290
1393
  "schema": {
1291
1394
  "aliases": [],
1292
1395
  "args": {
@@ -2586,5 +2689,5 @@
2586
2689
  ]
2587
2690
  }
2588
2691
  },
2589
- "version": "0.0.15"
2692
+ "version": "0.0.16"
2590
2693
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "packageManager": "yarn@4.11.0+sha512.4e54aeace9141df2f0177c266b05ec50dc044638157dae128c471ba65994ac802122d7ab35bcd9e81641228b7dcf24867d28e750e0bcae8a05277d600008ad54",
3
3
  "name": "@quonfig/cli",
4
- "version": "0.0.15",
4
+ "version": "0.0.16",
5
5
  "bugs": {
6
6
  "url": "https://github.com/quonfig/cli/issues"
7
7
  },