@quonfig/cli 0.0.15 → 0.0.17

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 (46) hide show
  1. package/README.md +85 -34
  2. package/dist/commands/init.d.ts +1 -0
  3. package/dist/commands/init.js +18 -0
  4. package/dist/commands/migrate.js +48 -2
  5. package/dist/commands/pull.d.ts +14 -1
  6. package/dist/commands/pull.js +64 -4
  7. package/dist/commands/push.d.ts +53 -0
  8. package/dist/commands/push.js +370 -0
  9. package/dist/commands/workspace/bootstrap.d.ts +7 -0
  10. package/dist/commands/workspace/bootstrap.js +58 -2
  11. package/dist/migrate/local-write.d.ts +8 -2
  12. package/dist/migrate/local-write.js +55 -7
  13. package/dist/migrate/migration-report.d.ts +18 -0
  14. package/dist/migrate/migration-report.js +64 -1
  15. package/dist/migrate/push-to-cloud.d.ts +14 -3
  16. package/dist/migrate/push-to-cloud.js +77 -6
  17. package/dist/migrate/source.d.ts +50 -1
  18. package/dist/migrate/sources/launch/translate.d.ts +27 -1
  19. package/dist/migrate/sources/launch/translate.js +123 -6
  20. package/dist/migrate/sources/launch/types.d.ts +3 -0
  21. package/dist/migrate/sources/launch/zod-to-json-schema.d.ts +9 -0
  22. package/dist/migrate/sources/launch/zod-to-json-schema.js +521 -0
  23. package/dist/migrate/sources/launch.js +52 -7
  24. package/dist/push/bare-path-diff.d.ts +40 -0
  25. package/dist/push/bare-path-diff.js +126 -0
  26. package/dist/push/confirm.d.ts +10 -0
  27. package/dist/push/confirm.js +108 -0
  28. package/dist/push/diff-summary.d.ts +56 -0
  29. package/dist/push/diff-summary.js +115 -0
  30. package/dist/push/identity-check.d.ts +53 -0
  31. package/dist/push/identity-check.js +135 -0
  32. package/dist/push/run-push.d.ts +107 -0
  33. package/dist/push/run-push.js +318 -0
  34. package/dist/util/get-client.js +5 -4
  35. package/dist/util/get-valid-token.d.ts +3 -1
  36. package/dist/util/get-valid-token.js +28 -3
  37. package/dist/util/gitea-api.d.ts +15 -1
  38. package/dist/util/gitea-api.js +1 -0
  39. package/dist/util/gitea-token-storage.d.ts +7 -0
  40. package/dist/util/quonfig-json.d.ts +44 -0
  41. package/dist/util/quonfig-json.js +65 -0
  42. package/dist/verify/validate.js +6 -2
  43. package/oclif.manifest.json +104 -1
  44. package/package.json +3 -3
  45. /package/dist/{migrate/push-strategy.d.ts → util/clone-and-stack-push.d.ts} +0 -0
  46. /package/dist/{migrate/push-strategy.js → util/clone-and-stack-push.js} +0 -0
@@ -0,0 +1,107 @@
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 { PushIdentity } from '../util/clone-and-stack-push.js';
22
+ import { FileDelta } from './diff-summary.js';
23
+ export interface GiteaTokenMintResult {
24
+ expiresAt: string | null;
25
+ repoUrl: string;
26
+ token: string;
27
+ workspaceId: string;
28
+ workspaceSlug: string;
29
+ }
30
+ export interface GitOps {
31
+ /** Number of tracked files on origin/main (used for the destructive-ratio heuristic). */
32
+ countFilesInRemote(dir: string): Promise<number>;
33
+ /**
34
+ * Produce the list of file deltas to render in the Guard 3 summary and
35
+ * (for the bare path) commit. For the clone path this is HEAD vs origin/main.
36
+ * For the bare path the caller hands us the deltas itself.
37
+ */
38
+ diffHeadVsOrigin(dir: string): Promise<FileDelta[]>;
39
+ /** `git fetch origin` in the given dir. */
40
+ fetch(dir: string): Promise<void>;
41
+ /** Read local git config for author identity (used for bare-path commits). */
42
+ getLocalAuthor(dir: string): Promise<PushIdentity | undefined>;
43
+ /** Returns the `remote.origin.url` for the repo, or undefined if unset / not a repo. */
44
+ getRemoteOriginUrl(dir: string): Promise<string | undefined>;
45
+ /** Returns true if the dir has a `.git/` (worktree or repo). */
46
+ isGitRepo(dir: string): Promise<boolean>;
47
+ /** `git push origin main`. Throws with a PushConflict-ish message on non-ff. */
48
+ push(dir: string): Promise<void>;
49
+ /** Set origin to `url` (add if missing, set-url if present). */
50
+ setRemoteOrigin(dir: string, url: string): Promise<void>;
51
+ }
52
+ export type ConfirmIO = {
53
+ input?: NodeJS.ReadableStream;
54
+ output?: NodeJS.WritableStream;
55
+ };
56
+ export interface RunPushInput {
57
+ /** Absolute path to the local dir the user is pushing. */
58
+ dir: string;
59
+ /** `--message` — optional commit message override (bare path only). */
60
+ message?: string;
61
+ /** `--no-pin-write` — do not offer to write the slug pin into quonfig.json. */
62
+ noPinWrite: boolean;
63
+ /** `--workspace` flag, or the active profile's workspace UUID. Must be non-empty. */
64
+ requestedTarget: string;
65
+ /** `--skip-validate` — suppress the validate step. */
66
+ skipValidate: boolean;
67
+ /** `--yes` — skip standard Y/N confirm. Never skips typed-slug. */
68
+ yes: boolean;
69
+ }
70
+ export interface RunPushDeps {
71
+ /** Optional io streams for confirmation prompts. Defaults to process stdin/stdout. */
72
+ confirmIO?: ConfirmIO;
73
+ /**
74
+ * Copy files from `sourceDir` into `destDir`, skipping `.git/`. Used by the
75
+ * bare path as the `applyDelta` callback handed to `cloneAndStackPush`.
76
+ */
77
+ copyDirMirror(sourceDir: string, destDir: string): Promise<void>;
78
+ /** Error logger; defaults to console.error. */
79
+ errLog?: (line: string) => void;
80
+ gitOps: GitOps;
81
+ /** Logger; defaults to console.log. */
82
+ log?: (line: string) => void;
83
+ /** Mint a write token via the backend. */
84
+ mintWriteToken(requestedTarget: string): Promise<GiteaTokenMintResult>;
85
+ /** Run `qfg validate` semantics. Should throw on error. */
86
+ validate(dir: string): Promise<{
87
+ errors: string[];
88
+ }>;
89
+ }
90
+ export type RunPushResult = {
91
+ kind: 'aborted';
92
+ reason: string;
93
+ } | {
94
+ kind: 'pushed';
95
+ dispatchedAs: 'clone-path' | 'bare-path';
96
+ commitSha?: string | null;
97
+ } | {
98
+ kind: 'no-op';
99
+ reason: string;
100
+ };
101
+ export declare class PushFatalError extends Error {
102
+ code: string;
103
+ constructor(message: string, code?: string);
104
+ }
105
+ /** Append the Pushed-Via trailer to a commit message if it isn't already present. */
106
+ export declare const withPushedViaTrailer: (message: string) => string;
107
+ export declare function runPush(input: RunPushInput, deps: RunPushDeps): Promise<RunPushResult>;
@@ -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
+ };
@@ -29,7 +29,7 @@ const getClient = async (command, sdkKey, profile) => {
29
29
  // API-key (workspace-scoped bearer) path. getValidAccessToken short-circuits
30
30
  // when QUONFIG_API_KEY is set, so this never touches disk.
31
31
  try {
32
- jwt = await getValidAccessToken();
32
+ jwt = await getValidAccessToken(command.verboseLog);
33
33
  }
34
34
  catch (error) {
35
35
  command.error(error instanceof Error ? error.message : String(error), { exit: 1 });
@@ -81,11 +81,12 @@ const getClient = async (command, sdkKey, profile) => {
81
81
  }
82
82
  command.verboseLog('Checking token validity...');
83
83
  try {
84
- jwt = await getValidAccessToken();
84
+ jwt = await getValidAccessToken(command.verboseLog);
85
85
  command.verboseLog('Token valid (or refreshed)');
86
86
  }
87
- catch {
88
- command.error('Session expired. Please run `qfg login` to re-authenticate.', { exit: 401 });
87
+ catch (error) {
88
+ const detail = error instanceof Error ? error.message : String(error);
89
+ command.error(`Session expired. Please run \`qfg login\` to re-authenticate. (${detail})`, { exit: 401 });
89
90
  }
90
91
  // Resolve workspace: --workspace/QUONFIG_WORKSPACE (slug) → QUONFIG_PROFILE (profile name) → default
91
92
  const workspaceSlugOverride = profile ?? process.env.QUONFIG_WORKSPACE;
@@ -6,4 +6,6 @@
6
6
  * directly as a bearer token and no disk reads/writes or refresh calls
7
7
  * are made. This is the non-interactive (CI) path.
8
8
  */
9
- export declare function getValidAccessToken(): Promise<string>;
9
+ type Logger = (msg: string, data?: unknown) => void;
10
+ export declare function getValidAccessToken(log?: Logger): Promise<string>;
11
+ export {};
@@ -6,38 +6,62 @@
6
6
  * directly as a bearer token and no disk reads/writes or refresh calls
7
7
  * are made. This is the non-interactive (CI) path.
8
8
  */
9
+ import { getDomain } from './domain-urls.js';
9
10
  import { decodeJWT, refreshAccessToken } from './oauth-client.js';
10
11
  import { loadTokens, saveTokens } from './token-storage.js';
11
- export async function getValidAccessToken() {
12
+ const noopLog = () => { };
13
+ export async function getValidAccessToken(log = noopLog) {
12
14
  const envKey = process.env.QUONFIG_API_KEY;
13
15
  if (envKey && envKey.length > 0) {
14
16
  if (!envKey.startsWith('qf_uk_')) {
15
17
  throw new Error(`QUONFIG_API_KEY must start with "qf_uk_" (got "${envKey.slice(0, 8)}..."). Generate one from the web UI under Settings → API Keys.`);
16
18
  }
19
+ log('getValidAccessToken: using QUONFIG_API_KEY (CI path)');
17
20
  return envKey;
18
21
  }
19
22
  const tokens = await loadTokens();
23
+ log('getValidAccessToken: loaded tokens', {
24
+ domain: getDomain(),
25
+ hasAccessToken: Boolean(tokens?.accessToken),
26
+ hasRefreshToken: Boolean(tokens?.refreshToken),
27
+ expiresAt: tokens?.expiresAt,
28
+ });
20
29
  if (!tokens?.accessToken) {
21
30
  throw new Error('Not authenticated. Please run `qfg login` first.');
22
31
  }
23
32
  // Check expiry using the JWT's exp claim, falling back to stored expiresAt
24
33
  let expired = tokens.expiresAt ? tokens.expiresAt < Date.now() : false;
34
+ let expirySource = 'stored';
25
35
  try {
26
36
  const payload = decodeJWT(tokens.accessToken);
27
37
  if (typeof payload.exp === 'number') {
28
38
  expired = payload.exp * 1000 < Date.now();
39
+ expirySource = 'jwt';
29
40
  }
30
41
  }
31
42
  catch {
32
43
  /* use stored expiresAt */
33
44
  }
45
+ log('getValidAccessToken: expiry check', { expired, expirySource, now: Date.now() });
34
46
  if (!expired) {
35
47
  return tokens.accessToken;
36
48
  }
37
49
  if (!tokens.refreshToken) {
38
- throw new Error('Session expired. Please run `qfg login` to re-authenticate.');
50
+ throw new Error('Session expired (no refresh_token on disk). Please run `qfg login` to re-authenticate.');
39
51
  }
40
- const refreshed = await refreshAccessToken(tokens.refreshToken);
52
+ log('getValidAccessToken: access token expired, calling WorkOS refresh');
53
+ let refreshed;
54
+ try {
55
+ refreshed = await refreshAccessToken(tokens.refreshToken);
56
+ }
57
+ catch (error) {
58
+ const detail = error instanceof Error ? error.message : String(error);
59
+ log('getValidAccessToken: refresh FAILED', { detail });
60
+ throw new Error(`Token refresh failed: ${detail}`);
61
+ }
62
+ log('getValidAccessToken: refresh succeeded', {
63
+ gotNewRefreshToken: Boolean(refreshed.refresh_token),
64
+ });
41
65
  let expiresAt = Date.now() + 300 * 1000;
42
66
  try {
43
67
  const payload = decodeJWT(refreshed.access_token);
@@ -55,5 +79,6 @@ export async function getValidAccessToken() {
55
79
  refreshToken: refreshed.refresh_token,
56
80
  };
57
81
  await saveTokens(updated);
82
+ log('getValidAccessToken: refreshed tokens saved', { newExpiresAt: expiresAt });
58
83
  return refreshed.access_token;
59
84
  }
@@ -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>;