@quonfig/cli 0.0.25 → 0.0.26

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 (44) hide show
  1. package/README.md +47 -47
  2. package/dist/commands/create.d.ts +7 -0
  3. package/dist/commands/create.js +33 -0
  4. package/dist/commands/delete.js +4 -2
  5. package/dist/commands/init.js +6 -2
  6. package/dist/commands/login.js +72 -91
  7. package/dist/commands/migrate/doctor.js +13 -3
  8. package/dist/commands/override.js +4 -2
  9. package/dist/commands/pull.d.ts +11 -0
  10. package/dist/commands/pull.js +67 -20
  11. package/dist/commands/push.js +4 -7
  12. package/dist/commands/whoami.d.ts +13 -0
  13. package/dist/commands/whoami.js +119 -32
  14. package/dist/commands/workspace/bootstrap.js +27 -20
  15. package/dist/commands/workspace/create.d.ts +11 -0
  16. package/dist/commands/workspace/create.js +56 -16
  17. package/dist/commands/workspace/switch.d.ts +10 -0
  18. package/dist/commands/workspace/switch.js +98 -50
  19. package/dist/commands/workspace.d.ts +14 -0
  20. package/dist/commands/workspace.js +152 -40
  21. package/dist/migrate/local-write.js +16 -31
  22. package/dist/push/bare-path-diff.js +2 -4
  23. package/dist/push/config-push-client.js +4 -2
  24. package/dist/push/run-push.js +37 -8
  25. package/dist/util/clone-and-stack-push.js +14 -29
  26. package/dist/util/get-client.js +70 -38
  27. package/dist/util/get-valid-token.d.ts +16 -4
  28. package/dist/util/get-valid-token.js +59 -42
  29. package/dist/util/git-ops.d.ts +90 -0
  30. package/dist/util/git-ops.js +172 -24
  31. package/dist/util/gitea-api.js +4 -2
  32. package/dist/util/local-config-writer.d.ts +33 -0
  33. package/dist/util/local-config-writer.js +63 -0
  34. package/dist/util/login-orchestrator.d.ts +42 -0
  35. package/dist/util/login-orchestrator.js +79 -0
  36. package/dist/util/oauth-client.d.ts +2 -0
  37. package/dist/util/oauth-client.js +30 -0
  38. package/dist/util/quonfig-json.d.ts +58 -21
  39. package/dist/util/quonfig-json.js +151 -22
  40. package/dist/util/resolve-workspace.js +79 -17
  41. package/dist/util/token-storage.d.ts +39 -8
  42. package/dist/util/token-storage.js +34 -4
  43. package/oclif.manifest.json +8 -8
  44. package/package.json +1 -1
@@ -1,9 +1,12 @@
1
1
  import { Client } from '@quonfig/node';
2
2
  import { getApiUrl } from '../util/domain-urls.js';
3
3
  import jsonMaybe from '../util/json-maybe.js';
4
- import { getActiveProfile, loadAuthConfig, loadTokens, resolveWorkspaceId } from '../util/token-storage.js';
5
- import { getValidAccessToken } from '../util/get-valid-token.js';
4
+ import { tryParseWorkspacePin } from '../util/quonfig-json.js';
5
+ import { findOrgIdBySlug, getActiveProfile, loadAuthConfig, loadTokens } from '../util/token-storage.js';
6
+ import { getValidAccessToken, resolveDefaultOrgId } from '../util/get-valid-token.js';
6
7
  import version from '../version.js';
8
+ const BARE_SLUG_ENV_MIGRATION_MESSAGE = 'QUONFIG_WORKSPACE must be in org/workspace form (e.g. acme/foo). ' +
9
+ 'Bare workspace slugs are no longer accepted. Update your .env and run `qfg login` if you have not yet migrated.';
7
10
  let clientInstance;
8
11
  let cachedWorkspaceId;
9
12
  export const resetClientCache = () => {
@@ -27,9 +30,9 @@ const getClient = async (command, sdkKey, profile) => {
27
30
  }
28
31
  else if (apiKey && apiKey.length > 0) {
29
32
  // API-key (workspace-scoped bearer) path. getValidAccessToken short-circuits
30
- // when QUONFIG_API_KEY is set, so this never touches disk.
33
+ // when QUONFIG_API_KEY is set, so this never touches disk and orgId is ignored.
31
34
  try {
32
- jwt = await getValidAccessToken(command.verboseLog);
35
+ jwt = await getValidAccessToken('', command.verboseLog);
33
36
  }
34
37
  catch (error) {
35
38
  command.error(error instanceof Error ? error.message : String(error), { exit: 1 });
@@ -70,46 +73,75 @@ const getClient = async (command, sdkKey, profile) => {
70
73
  }
71
74
  }
72
75
  else {
73
- const authConfig = await loadAuthConfig();
74
- const tokens = await loadTokens();
75
- command.verboseLog('OAuth auth', {
76
- hasAuthConfig: Boolean(authConfig),
77
- hasAccessToken: Boolean(tokens?.accessToken),
78
- });
79
- if (!authConfig || !tokens?.accessToken) {
80
- command.error('No authentication found. Please run `qfg login`.', { exit: 401 });
81
- }
82
- command.verboseLog('Checking token validity...');
83
- try {
84
- jwt = await getValidAccessToken(command.verboseLog);
85
- command.verboseLog('Token valid (or refreshed)');
86
- }
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 });
90
- }
91
- // Resolve workspace: --workspace/QUONFIG_WORKSPACE (slug) → QUONFIG_PROFILE (profile name) → default
76
+ // OAuth path: --workspace/QUONFIG_WORKSPACE must be in `org/ws` form so we
77
+ // can mint the org-scoped JWT for the right org and resolve the workspace
78
+ // slug *within that org*. No override → use the saved default profile.
92
79
  const workspaceSlugOverride = profile ?? process.env.QUONFIG_WORKSPACE;
93
80
  const profileNameOverride = process.env.QUONFIG_PROFILE;
81
+ const store = await loadTokens();
82
+ if (!store || Object.keys(store.tokensByOrg).length === 0) {
83
+ command.error('No authentication found. Please run `qfg login`.', { exit: 401 });
84
+ }
94
85
  if (workspaceSlugOverride) {
95
- // Try slug resolution first, then fall back to treating it as a profile name
96
- const resolved = resolveWorkspaceId(authConfig, workspaceSlugOverride);
97
- if (resolved) {
98
- workspaceId = resolved;
99
- command.verboseLog('Workspace lookup', { source: 'slug', slug: workspaceSlugOverride, workspaceId });
100
- }
101
- else {
102
- const profileData = authConfig.profiles[workspaceSlugOverride];
103
- if (profileData) {
104
- workspaceId = profileData.workspace;
105
- command.verboseLog('Workspace lookup', { source: 'profile-name', profile: workspaceSlugOverride, workspaceId });
106
- }
107
- else {
108
- command.error(`Workspace '${workspaceSlugOverride}' not found. Run \`qfg workspace switch\` to select and save a workspace.`, { exit: 1 });
109
- }
86
+ const pin = tryParseWorkspacePin(workspaceSlugOverride);
87
+ if (!pin) {
88
+ command.error(BARE_SLUG_ENV_MIGRATION_MESSAGE, { exit: 1 });
89
+ }
90
+ const { orgSlug, workspaceSlug } = pin;
91
+ const orgId = findOrgIdBySlug(store, orgSlug);
92
+ if (!orgId) {
93
+ command.error(`No token found for org \`${orgSlug}\`. Run \`qfg login\` to add this org.`, { exit: 401 });
110
94
  }
95
+ try {
96
+ jwt = await getValidAccessToken(orgId, command.verboseLog);
97
+ }
98
+ catch (error) {
99
+ const detail = error instanceof Error ? error.message : String(error);
100
+ command.error(`Session expired for org ${orgSlug}: ${detail}. Run \`qfg login\` to re-authenticate.`, {
101
+ exit: 401,
102
+ });
103
+ }
104
+ let res;
105
+ try {
106
+ res = await fetch(`${getApiUrl()}/api/v1/userWorkspaces/list`, {
107
+ method: 'POST',
108
+ headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
109
+ body: JSON.stringify({ json: {} }),
110
+ });
111
+ }
112
+ catch (error) {
113
+ command.error(`Failed to resolve workspace "${workspaceSlugOverride}": ${error instanceof Error ? error.message : String(error)}`, { exit: 1 });
114
+ }
115
+ if (!res.ok) {
116
+ command.error(`Failed to resolve workspace "${workspaceSlugOverride}" (HTTP ${res.status}).`, { exit: 1 });
117
+ }
118
+ const body = (await res.json());
119
+ const entries = (body.json ?? body);
120
+ const candidates = Array.isArray(entries) ? entries : [];
121
+ const match = candidates.find((w) => w.workosOrgId === orgId && w.workspaceSlug === workspaceSlug);
122
+ if (!match) {
123
+ const inOrg = candidates.filter((w) => w.workosOrgId === orgId).map((w) => w.workspaceSlug);
124
+ const available = inOrg.length > 0 ? inOrg.join(', ') : '(none)';
125
+ command.error(`Workspace "${workspaceSlug}" not found in org "${orgSlug}". Available: ${available}.`, { exit: 1 });
126
+ }
127
+ workspaceId = match.workspaceId;
128
+ command.verboseLog('Workspace lookup', { source: 'org/ws', orgSlug, workspaceSlug, orgId, workspaceId });
111
129
  }
112
130
  else {
131
+ const authConfig = await loadAuthConfig();
132
+ if (!authConfig) {
133
+ command.error('No authentication found. Please run `qfg login`.', { exit: 401 });
134
+ }
135
+ command.verboseLog('OAuth auth', { hasAuthConfig: true });
136
+ try {
137
+ const orgId = await resolveDefaultOrgId();
138
+ jwt = await getValidAccessToken(orgId, command.verboseLog);
139
+ command.verboseLog('Token valid (or refreshed)');
140
+ }
141
+ catch (error) {
142
+ const detail = error instanceof Error ? error.message : String(error);
143
+ command.error(`Session expired. Please run \`qfg login\` to re-authenticate. (${detail})`, { exit: 401 });
144
+ }
113
145
  const activeProfile = getActiveProfile(profileNameOverride);
114
146
  const profileData = authConfig.profiles[activeProfile] || authConfig.profiles[authConfig.defaultProfile || 'default'];
115
147
  command.verboseLog('Workspace lookup', {
@@ -1,11 +1,23 @@
1
1
  /**
2
- * Returns a valid (non-expired) WorkOS access token, refreshing if needed.
3
- * Throws if not logged in or if refresh fails.
2
+ * Returns a valid (non-expired) WorkOS access token for the given org,
3
+ * refreshing if needed. Throws if no token for that org is on disk or if
4
+ * refresh fails.
4
5
  *
5
6
  * If `QUONFIG_API_KEY` is set in the environment (non-empty), it is used
6
7
  * directly as a bearer token and no disk reads/writes or refresh calls
7
- * are made. This is the non-interactive (CI) path.
8
+ * are made. This is the non-interactive (CI) path; `workosOrgId` is
9
+ * ignored because API keys are workspace-scoped.
8
10
  */
9
11
  type Logger = (msg: string, data?: unknown) => void;
10
- export declare function getValidAccessToken(log?: Logger): Promise<string>;
12
+ /**
13
+ * Transitional helper: pick a default workosOrgId from the token store.
14
+ * Returns '' if no store exists (fine for the QUONFIG_API_KEY short-circuit
15
+ * path, which ignores the orgId argument).
16
+ *
17
+ * TODO(qfg-kr7.5/7/8/9): callers should pass through the workosOrgId resolved
18
+ * from the workspace address (org-slug/workspace-slug parsing). This helper
19
+ * is the stopgap until each caller's resolution lands in its own bead.
20
+ */
21
+ export declare function resolveDefaultOrgId(): Promise<string>;
22
+ export declare function getValidAccessToken(workosOrgId: string, log?: Logger): Promise<string>;
11
23
  export {};
@@ -1,16 +1,34 @@
1
1
  /**
2
- * Returns a valid (non-expired) WorkOS access token, refreshing if needed.
3
- * Throws if not logged in or if refresh fails.
2
+ * Returns a valid (non-expired) WorkOS access token for the given org,
3
+ * refreshing if needed. Throws if no token for that org is on disk or if
4
+ * refresh fails.
4
5
  *
5
6
  * If `QUONFIG_API_KEY` is set in the environment (non-empty), it is used
6
7
  * directly as a bearer token and no disk reads/writes or refresh calls
7
- * are made. This is the non-interactive (CI) path.
8
+ * are made. This is the non-interactive (CI) path; `workosOrgId` is
9
+ * ignored because API keys are workspace-scoped.
8
10
  */
9
- import { getDomain } from './domain-urls.js';
10
- import { decodeJWT, refreshAccessToken } from './oauth-client.js';
11
- import { loadTokens, saveTokens } from './token-storage.js';
11
+ import { authenticateWithOrg, decodeJWT } from './oauth-client.js';
12
+ import { getTokenForOrg, loadTokens, saveTokens } from './token-storage.js';
12
13
  const noopLog = () => { };
13
- export async function getValidAccessToken(log = noopLog) {
14
+ /**
15
+ * Transitional helper: pick a default workosOrgId from the token store.
16
+ * Returns '' if no store exists (fine for the QUONFIG_API_KEY short-circuit
17
+ * path, which ignores the orgId argument).
18
+ *
19
+ * TODO(qfg-kr7.5/7/8/9): callers should pass through the workosOrgId resolved
20
+ * from the workspace address (org-slug/workspace-slug parsing). This helper
21
+ * is the stopgap until each caller's resolution lands in its own bead.
22
+ */
23
+ export async function resolveDefaultOrgId() {
24
+ const store = await loadTokens();
25
+ if (!store)
26
+ return '';
27
+ if (store.defaultOrgId && store.tokensByOrg[store.defaultOrgId])
28
+ return store.defaultOrgId;
29
+ return Object.keys(store.tokensByOrg)[0] ?? '';
30
+ }
31
+ export async function getValidAccessToken(workosOrgId, log = noopLog) {
14
32
  const envKey = process.env.QUONFIG_API_KEY;
15
33
  if (envKey && envKey.length > 0) {
16
34
  if (!envKey.startsWith('qf_uk_')) {
@@ -19,66 +37,65 @@ export async function getValidAccessToken(log = noopLog) {
19
37
  log('getValidAccessToken: using QUONFIG_API_KEY (CI path)');
20
38
  return envKey;
21
39
  }
22
- const tokens = await loadTokens();
40
+ const store = await loadTokens();
41
+ const tokens = store ? getTokenForOrg(store, workosOrgId) : undefined;
23
42
  log('getValidAccessToken: loaded tokens', {
24
- domain: getDomain(),
25
- hasAccessToken: Boolean(tokens?.accessToken),
26
- hasRefreshToken: Boolean(tokens?.refreshToken),
27
- expiresAt: tokens?.expiresAt,
43
+ workosOrgId,
44
+ hasStore: Boolean(store),
45
+ hasAccessToken: Boolean(tokens?.access_token),
46
+ hasRefreshToken: Boolean(tokens?.refresh_token),
47
+ expiresAt: tokens?.expires_at,
28
48
  });
29
- if (!tokens?.accessToken) {
30
- throw new Error('Not authenticated. Please run `qfg login` first.');
49
+ if (!store || !tokens?.access_token) {
50
+ throw new Error(`No token for org ${workosOrgId}. Run \`qfg login\` to mint one.`);
31
51
  }
32
- // Check expiry using the JWT's exp claim, falling back to stored expiresAt
33
- let expired = tokens.expiresAt ? tokens.expiresAt < Date.now() : false;
52
+ // Check expiry using the JWT's exp claim, falling back to stored expires_at
53
+ let expired = tokens.expires_at ? tokens.expires_at < Date.now() : false;
34
54
  let expirySource = 'stored';
35
55
  try {
36
- const payload = decodeJWT(tokens.accessToken);
56
+ const payload = decodeJWT(tokens.access_token);
37
57
  if (typeof payload.exp === 'number') {
38
58
  expired = payload.exp * 1000 < Date.now();
39
59
  expirySource = 'jwt';
40
60
  }
41
61
  }
42
62
  catch {
43
- /* use stored expiresAt */
63
+ /* use stored expires_at */
44
64
  }
45
- log('getValidAccessToken: expiry check', { expired, expirySource, now: Date.now() });
65
+ log('getValidAccessToken: expiry check', { workosOrgId, expired, expirySource, now: Date.now() });
46
66
  if (!expired) {
47
- return tokens.accessToken;
67
+ return tokens.access_token;
48
68
  }
49
- if (!tokens.refreshToken) {
50
- throw new Error('Session expired (no refresh_token on disk). Please run `qfg login` to re-authenticate.');
69
+ if (!tokens.refresh_token) {
70
+ throw new Error(`Session expired for org ${workosOrgId} (no refresh_token on disk). Run \`qfg login\` to re-authenticate.`);
51
71
  }
52
- log('getValidAccessToken: access token expired, calling WorkOS refresh');
72
+ log('getValidAccessToken: access token expired, calling WorkOS refresh', { workosOrgId });
53
73
  let refreshed;
54
74
  try {
55
- refreshed = await refreshAccessToken(tokens.refreshToken);
75
+ // authenticateWithOrg passes organization_id so the new access token is
76
+ // org-scoped and carries the user's permissions[] for that org. Refreshing
77
+ // without organization_id would mint a user-scoped token (the qfg-kr7
78
+ // root cause) that fails every write path.
79
+ refreshed = await authenticateWithOrg(tokens.refresh_token, workosOrgId);
56
80
  }
57
81
  catch (error) {
58
82
  const detail = error instanceof Error ? error.message : String(error);
59
- log('getValidAccessToken: refresh FAILED', { detail });
60
- throw new Error(`Token refresh failed: ${detail}`);
83
+ log('getValidAccessToken: refresh FAILED', { workosOrgId, detail });
84
+ throw new Error(`Token refresh failed for org ${workosOrgId}: ${detail}`);
61
85
  }
62
86
  log('getValidAccessToken: refresh succeeded', {
87
+ workosOrgId,
63
88
  gotNewRefreshToken: Boolean(refreshed.refresh_token),
64
89
  });
65
- let expiresAt = Date.now() + 300 * 1000;
66
- try {
67
- const payload = decodeJWT(refreshed.access_token);
68
- if (typeof payload.exp === 'number') {
69
- expiresAt = payload.exp * 1000;
70
- }
71
- }
72
- catch {
73
- /* use fallback */
74
- }
75
- const updated = {
90
+ store.tokensByOrg[workosOrgId] = {
76
91
  ...tokens,
77
- accessToken: refreshed.access_token,
78
- expiresAt,
79
- refreshToken: refreshed.refresh_token,
92
+ access_token: refreshed.access_token,
93
+ expires_at: refreshed.expires_at,
94
+ refresh_token: refreshed.refresh_token,
95
+ user_email: refreshed.user_email ?? tokens.user_email,
96
+ user_id: refreshed.user_id ?? tokens.user_id,
80
97
  };
81
- await saveTokens(updated);
82
- log('getValidAccessToken: refreshed tokens saved', { newExpiresAt: expiresAt });
98
+ await saveTokens(store);
99
+ log('getValidAccessToken: refreshed tokens saved', { workosOrgId, newExpiresAt: refreshed.expires_at });
83
100
  return refreshed.access_token;
84
101
  }
@@ -1,11 +1,101 @@
1
+ /**
2
+ * Args prepended to every git invocation. Empty `credential.helper` resets the
3
+ * helper chain for this child process only — prevents macOS git's osxkeychain
4
+ * helper from popping a "Keychain Not Found" dialog when qfg-managed creds are
5
+ * embedded in the URL.
6
+ */
7
+ export declare const GIT_SAFE_ARGS: readonly string[];
8
+ /**
9
+ * Env additions for every git invocation. Suppress tty prompts and Git
10
+ * Credential Manager interactive flows.
11
+ */
12
+ export declare const GIT_SAFE_ENV: Readonly<NodeJS.ProcessEnv>;
1
13
  /**
2
14
  * Redact a token from a URL string so it is safe to display to users.
3
15
  */
4
16
  export declare const redactToken: (url: string) => string;
17
+ export interface RunGitOptions {
18
+ cwd?: string;
19
+ /** Extra env vars merged on top of `process.env` and `GIT_SAFE_ENV`. */
20
+ env?: NodeJS.ProcessEnv;
21
+ }
22
+ /**
23
+ * Canonical entry point for shelling out to git from the CLI. Always prepends
24
+ * `GIT_SAFE_ARGS` and merges `GIT_SAFE_ENV` so credential prompts never leak
25
+ * to the user. Errors are re-thrown with tokens redacted from message/stderr.
26
+ */
27
+ export declare const runGit: (args: string[], options?: RunGitOptions) => Promise<{
28
+ stdout: string;
29
+ stderr: string;
30
+ }>;
31
+ export interface SpawnGitOptions {
32
+ cwd?: string;
33
+ env?: NodeJS.ProcessEnv;
34
+ /** If set, written to the child's stdin and then stdin is closed. */
35
+ stdin?: string;
36
+ }
37
+ /**
38
+ * Spawn-based git invocation for cases where stdin needs to be piped (e.g.
39
+ * `git commit -F -`). Same safe-args/env injection as `runGit`.
40
+ */
41
+ export declare const spawnGit: (args: string[], options?: SpawnGitOptions) => Promise<void>;
5
42
  export declare const gitClone: (repoUrl: string, dir: string) => Promise<void>;
6
43
  export declare const isGitRepo: (dir: string) => Promise<boolean>;
7
44
  export declare const getRemoteUrl: (dir: string) => Promise<string | null>;
8
45
  export declare const isWorkingTreeClean: (dir: string) => Promise<boolean>;
46
+ /**
47
+ * Returns true if `file` (relative to `dir`) has any working-tree or
48
+ * staged change, false if its tracked content matches HEAD.
49
+ */
50
+ export declare const hasFileChanges: (dir: string, file: string) => Promise<boolean>;
51
+ /**
52
+ * Returns the list of tracked files (relative paths) with working-tree
53
+ * or staged modifications. Untracked files (`??`) are excluded — they
54
+ * are not considered "dirty" by callers that want to know whether the
55
+ * user has work-in-progress that should not be swept into a commit.
56
+ */
57
+ export declare const dirtyTrackedFiles: (dir: string) => Promise<string[]>;
58
+ /**
59
+ * Stage and commit a single file with the given message. The path argument
60
+ * to `git commit` ensures only this file is committed even if other files
61
+ * are already staged in the index.
62
+ *
63
+ * Returns true if a commit was created, false if there was nothing to
64
+ * commit (file matches HEAD already). Throws on real git errors.
65
+ */
66
+ export declare const addAndCommitFile: (dir: string, file: string, message: string) => Promise<boolean>;
67
+ /**
68
+ * Read the contents of `file` (relative to `dir`) at HEAD. Returns
69
+ * `undefined` if the file does not exist at HEAD or if HEAD itself is
70
+ * unset (empty repo).
71
+ */
72
+ export declare const readFileAtHead: (dir: string, file: string) => Promise<string | undefined>;
73
+ /**
74
+ * Result of `commitPinFixIfPinOnly`. Tells callers whether a commit was
75
+ * made and, if not, why — useful for verbose logging without throwing on
76
+ * the migration path.
77
+ */
78
+ export type PinFixResult = {
79
+ kind: 'committed';
80
+ slug: string;
81
+ } | {
82
+ kind: 'clean';
83
+ } | {
84
+ kind: 'skipped';
85
+ reason: string;
86
+ };
87
+ /**
88
+ * Migration helper for legacy state where `qfg pull` wrote the workspace
89
+ * pin to the working tree but never committed it (qfg-0fn). If
90
+ * `quonfig.json` is dirty AND its only diff vs HEAD is an added or
91
+ * changed `workspace` key matching `expectedSlug`, stage and commit the
92
+ * file so push's HEAD-vs-origin delta picks it up.
93
+ *
94
+ * Skips (returns `kind: 'skipped'`) when the dirty file has any other
95
+ * changes, when the pin doesn't match the backend slug, or when JSON
96
+ * parsing fails — leaving the user's working tree alone.
97
+ */
98
+ export declare const commitPinFixIfPinOnly: (dir: string, file: string, expectedSlug: string) => Promise<PinFixResult>;
9
99
  export declare const gitFetch: (dir: string) => Promise<void>;
10
100
  /**
11
101
  * Returns true if origin/main has commits that can be fast-forwarded into the local branch.