gipity 1.1.4 → 1.1.5

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.
@@ -4,7 +4,7 @@ import { join, resolve } from 'path';
4
4
  import { homedir } from 'os';
5
5
  import { getAuth, sessionExpired } from '../auth.js';
6
6
  import { get, usingEnvToken, ApiError } from '../api.js';
7
- import { getConfig, liveUrl } from '../config.js';
7
+ import { getConfig, liveUrl, resolveApiBase } from '../config.js';
8
8
  import { brand, success, warning, muted, error as clrError } from '../colors.js';
9
9
  import { GIPITY_PLUGIN_ID, GIPITY_MARKETPLACE_NAME, setupClaudeHooks, ensureGipityPlugin, ensureGipityPluginInstalled, userScopeInstallState } from '../setup.js';
10
10
  import { flushBugQueue } from '../bug-queue.js';
@@ -39,15 +39,20 @@ function checkGipityPlugin() {
39
39
  const stale = missing.length === 1 && missing[0] === 'install' && install.exists;
40
40
  return { missing, ok: missing.length === 0, stale };
41
41
  }
42
+ /** `account` is the server's `account_slug` for the authenticated identity
43
+ * (only populated on 'ok' - the /users/me call that proved the probe also
44
+ * returns it) - used for the ownership cross-check against the locally
45
+ * cached project (bug cli#S2: a wrong-account session used to read as
46
+ * fully healthy until a later command 404'd). */
42
47
  async function probeAuth(loggedIn) {
43
48
  if (!loggedIn && !usingEnvToken())
44
- return 'none';
49
+ return { state: 'none', account: null };
45
50
  if (!usingEnvToken() && sessionExpired())
46
- return 'expired';
51
+ return { state: 'expired', account: null };
47
52
  // Cap the probe well below the API layer's 60s request timeout - status is
48
53
  // a diagnostic command and must answer fast even when the network is dark.
49
- const timeout = new Promise(res => setTimeout(() => res('unreachable'), 5000).unref?.());
50
- const call = get('/users/me').then(() => 'ok', (err) => (err instanceof ApiError && err.statusCode === 401 ? 'rejected' : 'unreachable'));
54
+ const timeout = new Promise(res => setTimeout(() => res({ state: 'unreachable', account: null }), 5000).unref?.());
55
+ const call = get('/users/me').then((res) => ({ state: 'ok', account: res.data?.accountSlug ?? null }), (err) => ({ state: (err instanceof ApiError && err.statusCode === 401 ? 'rejected' : 'unreachable'), account: null }));
51
56
  return Promise.race([call, timeout]);
52
57
  }
53
58
  // `whoami` is the name agents reach for first when they want the signed-in
@@ -71,6 +76,12 @@ export const statusCommand = new Command('status')
71
76
  // is empty, which is the common case, so this stays a no-op most runs.
72
77
  const queueDelivered = (auth && !sessionExpired()) ? await flushBugQueue().catch(() => 0) : 0;
73
78
  const probe = await probeAuth(!!auth);
79
+ // RBAC lets a project be shared to a collaborator whose own account
80
+ // legitimately differs from the project owner's - so this is advisory,
81
+ // never a hard error. Only meaningful once a live 'ok' call has actually
82
+ // returned an account to compare against config.accountSlug.
83
+ const accountMismatch = probe.state === 'ok' && !!config && !!probe.account && probe.account !== config.accountSlug;
84
+ const apiBaseInUse = resolveApiBase();
74
85
  if (opts.json) {
75
86
  console.log(JSON.stringify({
76
87
  project: config ? {
@@ -78,17 +89,21 @@ export const statusCommand = new Command('status')
78
89
  slug: config.projectSlug,
79
90
  account: config.accountSlug,
80
91
  apiBase: config.apiBase,
92
+ apiBaseInUse,
81
93
  url: liveUrl(config),
82
94
  } : null,
83
95
  // `valid` reflects the refresh token (the real session) - access
84
96
  // tokens auto-renew, so their expiry must not read as "invalid".
85
97
  // `probe` is what one live call just proved: 'rejected' means every
86
98
  // authenticated command will fail even though `valid` reads true.
99
+ // 'mismatch' overrides 'ok' when the live account isn't the one that
100
+ // owns this project - `valid` still reads true (the token IS valid).
87
101
  auth: (auth || usingEnvToken()) ? {
88
102
  email: auth?.email,
103
+ account: probe.account,
89
104
  source: usingEnvToken() ? 'agent-token' : 'session',
90
- valid: usingEnvToken() ? probe !== 'rejected' : !sessionExpired(),
91
- probe,
105
+ valid: usingEnvToken() ? probe.state !== 'rejected' : !sessionExpired(),
106
+ probe: accountMismatch ? 'mismatch' : probe.state,
92
107
  } : null,
93
108
  plugin: hookCheck,
94
109
  }, null, 2));
@@ -102,28 +117,42 @@ export const statusCommand = new Command('status')
102
117
  console.log(`${muted('Account:')} ${config.accountSlug}`);
103
118
  console.log(`${muted('Live:')} ${liveUrl(config)}`);
104
119
  console.log(`${muted('API:')} ${config.apiBase}`);
120
+ // apiBase is only what THIS project recorded - resolveApiBase() is what
121
+ // every real request actually uses (it can diverge via GIPITY_API_BASE,
122
+ // --api-base, or a disallowed host being dropped to the default). Surface
123
+ // the divergence rather than silently trusting the recorded value.
124
+ if (apiBaseInUse !== config.apiBase) {
125
+ console.log(`${muted('API (in use):')} ${warning(apiBaseInUse)} ${muted('(overrides .gipity.json — GIPITY_API_BASE / --api-base / allowlist)')}`);
126
+ }
105
127
  if (config.agentGuid)
106
128
  console.log(`${muted('Agent:')} ${config.agentGuid}`);
107
129
  }
108
130
  if (usingEnvToken()) {
109
- console.log(`${muted('Auth:')} ${probe === 'rejected'
131
+ console.log(`${muted('Auth:')} ${probe.state === 'rejected'
110
132
  ? warning('agent API token (GIPITY_TOKEN) rejected by the server — mint a new one: gipity skill read agent-deploy')
111
- : success('agent API token (GIPITY_TOKEN)')}${probe === 'unreachable' ? ` ${muted('(unverified — API unreachable)')}` : ''}`);
133
+ : success('agent API token (GIPITY_TOKEN)')}${probe.state === 'unreachable' ? ` ${muted('(unverified — API unreachable)')}` : ''}`);
112
134
  }
113
135
  else if (!auth) {
114
136
  console.log(`${muted('Auth:')} ${warning('not logged in. Run: gipity login')}`);
115
137
  }
116
- else if (probe === 'expired') {
138
+ else if (probe.state === 'expired') {
117
139
  console.log(`${muted('Auth:')} ${warning(`session expired for ${auth.email}. Run: gipity login (headless/CI: set GIPITY_TOKEN — gipity skill read agent-deploy)`)}`);
118
140
  }
119
- else if (probe === 'rejected') {
141
+ else if (probe.state === 'rejected') {
120
142
  // Locally fresh but the server says no (refresh token rotated away or
121
143
  // revoked). Without the live probe this printed a green identity while
122
144
  // every authenticated command failed.
123
145
  console.log(`${muted('Auth:')} ${warning(`session for ${auth.email} was rejected by the server. Run: gipity login (headless/CI: set GIPITY_TOKEN — gipity skill read agent-deploy)`)}`);
124
146
  }
125
147
  else {
126
- console.log(`${muted('Auth:')} ${success(auth.email)}${probe === 'unreachable' ? ` ${muted('(unverified — API unreachable)')}` : ''}`);
148
+ console.log(`${muted('Auth:')} ${success(auth.email)}${probe.state === 'unreachable' ? ` ${muted('(unverified — API unreachable)')}` : ''}`);
149
+ }
150
+ // Source-independent (session or GIPITY_TOKEN): a mismatch under an agent
151
+ // token is the same wrong-account class and must not be hidden inside the
152
+ // cascade above, which only special-cases 'rejected' for that source.
153
+ if (accountMismatch) {
154
+ console.log(`${muted('Account:')} ${warning(`logged-in account (${probe.account}) differs from this project's account (${config.accountSlug}). If you didn't expect this you may be logged into the wrong account — run: gipity login`)}`);
155
+ console.log(muted('(If this project was shared with you via gipity rbac, this is expected.)'));
127
156
  }
128
157
  if (queueDelivered > 0) {
129
158
  console.log(`${muted('Bug queue:')} ${success(`delivered ${queueDelivered} queued bug report${queueDelivered === 1 ? '' : 's'}`)}`);
@@ -50,12 +50,12 @@ function printUsage(d) {
50
50
  console.log(`${bold('By project')} ${muted(scope)}`);
51
51
  for (const p of d.projects) {
52
52
  const name = p.projectName ?? '(no project)';
53
- row(name.length > 16 ? `${name.slice(0, 15)}…` : name, formatBytes(p.liveBytes), `${p.liveFiles.toLocaleString()} files`);
53
+ row(name.length > 16 ? `${name.slice(0, 13)}...` : name, formatBytes(p.liveBytes), `${p.liveFiles.toLocaleString()} files`);
54
54
  }
55
55
  if (hidden > 0) {
56
56
  const shownBytes = d.projects.reduce((n, p) => n + p.liveBytes, 0);
57
57
  const shownFiles = d.projects.reduce((n, p) => n + p.liveFiles, 0);
58
- row(`…and ${hidden.toLocaleString()} more`, formatBytes(totals.liveBytes - shownBytes), `${(totals.liveFiles - shownFiles).toLocaleString()} files`);
58
+ row(`...and ${hidden.toLocaleString()} more`, formatBytes(totals.liveBytes - shownBytes), `${(totals.liveFiles - shownFiles).toLocaleString()} files`);
59
59
  }
60
60
  }
61
61
  const r = d.versionRetention;
@@ -1,7 +1,10 @@
1
1
  import { Command } from 'commander';
2
+ import { dirname } from 'node:path';
2
3
  import { sync } from '../sync.js';
3
4
  import { createProgressReporter } from '../progress.js';
4
5
  import { error as clrError, muted } from '../colors.js';
6
+ import { findWindowsTwinProject, isWsl, wslPathToWindows } from '../utils.js';
7
+ import { getConfigPath } from '../config.js';
5
8
  export const syncCommand = new Command('sync')
6
9
  .description('Sync files')
7
10
  .addHelpText('after', '\nAdd a .gipityignore at the project root to exclude paths (gitignore-style).')
@@ -30,6 +33,18 @@ export const syncCommand = new Command('sync')
30
33
  // local). Surface that here as a helpful hint - not a red error - with
31
34
  // the one command that resolves it. Only `gipity sync` shows this;
32
35
  // deploy/test/sandbox stay silent (their internal sync defers quietly).
36
+ // WSL trap: a same-named folder under C:\Users\...\GipityProjects looks
37
+ // like "the project" from Windows Explorer, but files dropped there
38
+ // never sync. When a twin exists, say so - "Up to date." alone reads as
39
+ // "your new files are in" when they're sitting on the Windows side.
40
+ if (isWsl()) {
41
+ const configPath = getConfigPath();
42
+ const twin = configPath ? findWindowsTwinProject(dirname(configPath)) : null;
43
+ if (twin) {
44
+ console.log(muted(`\nNote: a Windows-side copy of this project exists at ${wslPathToWindows(twin)} and is NOT synced. ` +
45
+ `If you added files there, move them into this folder so they sync.`));
46
+ }
47
+ }
33
48
  if (result.deferredDeletes > 0) {
34
49
  console.log(muted(`\n${result.deferredDeletes} file${result.deferredDeletes > 1 ? 's' : ''} on Gipity ` +
35
50
  `${result.deferredDeletes > 1 ? 'are' : 'is'} not present locally and ${result.deferredDeletes > 1 ? 'were' : 'was'} left untouched. ` +
@@ -149,7 +149,7 @@ async function pollTestStatus(projectGuid, runGuid, opts) {
149
149
  const tally = data.passed + data.failed > 0
150
150
  ? ` (${data.passed} passed${data.failed > 0 ? `, ${data.failed} failed` : ''} so far)`
151
151
  : '';
152
- console.log(muted(` still running ${progress}${tally}, ${elapsed}s elapsed`));
152
+ console.log(muted(` ... still running - ${progress}${tally}, ${elapsed}s elapsed`));
153
153
  if (now - startTime >= LONG_RUN_MS && !longRunHintShown) {
154
154
  longRunHintShown = true;
155
155
  console.log(muted(' Note: progressing, not hung. LLM-backed tests can take minutes. To verify one function fast, use `gipity fn call <name>`; narrow this suite with `gipity test <path>`.'));
@@ -65,7 +65,7 @@ token-signed serve url instead (reachable only by holders of the url).`)
65
65
  };
66
66
  const data = opts.json
67
67
  ? await doUpload()
68
- : await withSpinner(`Uploading ${filename} (${formatSize(size)})…`, doUpload, { done: null });
68
+ : await withSpinner(`Uploading ${filename} (${formatSize(size)})...`, doUpload, { done: null });
69
69
  if (opts.json) {
70
70
  console.log(JSON.stringify(data));
71
71
  return;
@@ -186,7 +186,7 @@ workflowCommand
186
186
  }));
187
187
  workflowCommand
188
188
  .command('runs <name> [runGuid]')
189
- .description('List recent runs, or pass a run guid (wr_) to see that run\'s per-step outputs')
189
+ .description('List recent runs, or pass a run guid (wr_...) to see that run\'s per-step outputs')
190
190
  .option('--json', 'Output as JSON')
191
191
  .action((name, runGuid, _opts, cmd) => run('Runs', async () => {
192
192
  const opts = mergedOpts(cmd);