gipity 1.0.391 → 1.0.392

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api.js CHANGED
@@ -16,13 +16,23 @@ export class ApiError extends Error {
16
16
  this.name = 'ApiError';
17
17
  }
18
18
  }
19
- async function getHeaders() {
19
+ /** Resolve the Bearer token value. A GIPITY_TOKEN env var (a long-lived agent
20
+ * API token) takes precedence over the saved session — the persistent,
21
+ * login-free path for headless agents and CI. Falls back to the refreshed
22
+ * session access token. */
23
+ async function bearerToken() {
24
+ const envToken = process.env.GIPITY_TOKEN?.trim();
25
+ if (envToken)
26
+ return envToken;
20
27
  await refreshTokenIfNeeded();
21
28
  const auth = getAuth();
22
29
  if (!auth)
23
30
  throw new Error('Not authenticated. Run: gipity login');
31
+ return auth.accessToken;
32
+ }
33
+ async function getHeaders() {
24
34
  return {
25
- 'Authorization': `Bearer ${auth.accessToken}`,
35
+ 'Authorization': `Bearer ${await bearerToken()}`,
26
36
  'Content-Type': 'application/json',
27
37
  };
28
38
  }
@@ -125,13 +135,9 @@ export async function sendMessage(message) {
125
135
  }
126
136
  /** Download a file as raw bytes (no JSON parsing) */
127
137
  export async function download(path) {
128
- await refreshTokenIfNeeded();
129
- const auth = getAuth();
130
- if (!auth)
131
- throw new Error('Not authenticated. Run: gipity login');
132
138
  const url = `${baseUrl()}${path}`;
133
139
  const res = await fetch(url, {
134
- headers: { 'Authorization': `Bearer ${auth.accessToken}` },
140
+ headers: { 'Authorization': `Bearer ${await bearerToken()}` },
135
141
  });
136
142
  if (!res.ok) {
137
143
  throw new ApiError(res.status, 'DOWNLOAD_ERROR', `Download failed: ${res.statusText}`);
@@ -141,13 +147,9 @@ export async function download(path) {
141
147
  /** Download a response as a Node.js Readable stream */
142
148
  export async function downloadStream(path) {
143
149
  const { Readable } = await import('stream');
144
- await refreshTokenIfNeeded();
145
- const auth = getAuth();
146
- if (!auth)
147
- throw new Error('Not authenticated. Run: gipity login');
148
150
  const url = `${baseUrl()}${path}`;
149
151
  const res = await fetch(url, {
150
- headers: { 'Authorization': `Bearer ${auth.accessToken}` },
152
+ headers: { 'Authorization': `Bearer ${await bearerToken()}` },
151
153
  });
152
154
  if (!res.ok) {
153
155
  throw new ApiError(res.status, 'DOWNLOAD_ERROR', `Download failed: ${res.statusText}`);
package/dist/auth.js CHANGED
@@ -2,7 +2,11 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, chmodSy
2
2
  import { join } from 'path';
3
3
  import { homedir } from 'os';
4
4
  import { decodeJwtExp } from './utils.js';
5
- const AUTH_DIR = join(homedir(), '.gipity');
5
+ // GIPITY_DIR lets a caller keep a SEPARATE auth context (its own auth.json) from
6
+ // the default ~/.gipity — e.g. GipRunner logging into a local dev server without
7
+ // clobbering your real (prod) login. Only the auth dir moves; HOME is untouched,
8
+ // so the `claude` subprocess and git/npm still use the real home.
9
+ const AUTH_DIR = process.env.GIPITY_DIR || join(homedir(), '.gipity');
6
10
  const AUTH_FILE = join(AUTH_DIR, 'auth.json');
7
11
  let cached = null;
8
12
  export function getAuth() {
@@ -52,18 +56,10 @@ export function clearAuth() {
52
56
  catch { /* already gone */ }
53
57
  cached = null;
54
58
  }
55
- export function isExpired() {
56
- const auth = getAuth();
57
- if (!auth)
58
- return true;
59
- const expiresAt = new Date(auth.expiresAt).getTime();
60
- const buffer = 5 * 60 * 1000; // 5 minute buffer
61
- return Date.now() > expiresAt - buffer;
62
- }
63
59
  /** True only when re-login is genuinely required: the refresh token itself
64
- * has expired. Access-token expiry (`expiresAt` / isExpired) is invisible
65
- * to users — every API call renews it via refreshTokenIfNeeded() — so it
66
- * must never be surfaced as a session warning. */
60
+ * has expired. Access-token expiry (`expiresAt`) is invisible to users —
61
+ * every API call renews it via refreshTokenIfNeeded() — so it must never be
62
+ * surfaced as a session warning. */
67
63
  export function sessionExpired() {
68
64
  const auth = getAuth();
69
65
  if (!auth)
@@ -73,40 +69,78 @@ export function sessionExpired() {
73
69
  return false; // undecodable - let the refresh path decide
74
70
  return Date.now() > exp * 1000;
75
71
  }
72
+ const delay = (ms) => new Promise(r => setTimeout(r, ms));
73
+ /** Renew the access token (5-min buffer) before an authenticated call, surviving
74
+ * the case that broke overnight fix-mode runs: MANY concurrent `gipity` processes
75
+ * (relay daemon, file-sync hook, parallel commands) sharing one ~/.gipity/auth.json.
76
+ * Refresh tokens are SINGLE-USE — the server rotates them, so when several siblings
77
+ * race to refresh the same token, the first wins and the rest get a 401. The old
78
+ * code trusted a stale in-process cache and, on that race, let the 401 reach the
79
+ * caller, whose handler called clearAuth() and DELETED the shared file — locking
80
+ * every sibling out mid-run ("Not logged in"). Fix: always read the file fresh, and
81
+ * retry the race/transient failures, re-reading each attempt so we ADOPT whatever
82
+ * token a sibling just rotated in rather than resubmitting the rotated-away one.
83
+ * Stays void / never throws / never clears auth: a genuine dead token still flows to
84
+ * the caller's existing 401 path (which messages "run: gipity login"). */
76
85
  export async function refreshTokenIfNeeded() {
77
- if (!isExpired())
78
- return;
79
- const auth = getAuth();
86
+ const auth = readAuthFresh(); // never the cache — a sibling may have rotated
80
87
  if (!auth)
81
- return; // not logged in, caller will handle
82
- try {
83
- const config = await import('./config.js');
84
- const apiBase = config.resolveApiBase();
85
- const res = await fetch(`${apiBase}/auth/refresh`, {
86
- method: 'POST',
87
- headers: { 'Content-Type': 'application/json' },
88
- body: JSON.stringify({ refreshToken: auth.refreshToken }),
89
- });
90
- if (!res.ok) {
91
- cached = null; // force re-login
88
+ return; // not logged in - caller throws the clean error
89
+ cached = auth;
90
+ const buffer = 5 * 60 * 1000; // refresh 5 min before the access token lapses
91
+ const fresh = (a) => Date.now() <= new Date(a.expiresAt).getTime() - buffer;
92
+ if (fresh(auth))
93
+ return;
94
+ // If the refresh token itself has expired, re-login is genuinely required; leave the
95
+ // expired auth in place so the caller's existing 401 path prompts `gipity login`.
96
+ const refreshExp = decodeJwtExp(auth.refreshToken);
97
+ if (refreshExp && Date.now() > refreshExp * 1000)
98
+ return;
99
+ const { resolveApiBase } = await import('./config.js');
100
+ const apiBase = resolveApiBase();
101
+ for (let attempt = 1; attempt <= 3; attempt++) {
102
+ const cur = readAuthFresh(); // a sibling may have just refreshed for us
103
+ if (cur && fresh(cur)) {
104
+ cached = cur;
92
105
  return;
93
106
  }
94
- const json = await res.json();
95
- const exp = decodeJwtExp(json.accessToken);
96
- if (!exp) {
97
- cached = null;
107
+ const refreshToken = cur?.refreshToken ?? auth.refreshToken;
108
+ let res;
109
+ try {
110
+ res = await fetch(`${apiBase}/auth/refresh`, {
111
+ method: 'POST',
112
+ headers: { 'Content-Type': 'application/json' },
113
+ body: JSON.stringify({ refreshToken }),
114
+ });
115
+ }
116
+ catch {
117
+ await delay(attempt * 300);
118
+ continue; // network blip - retry
119
+ }
120
+ if (res.ok) {
121
+ const json = await res.json().catch(() => null);
122
+ const exp = json && decodeJwtExp(json.accessToken);
123
+ if (!json || !exp) {
124
+ await delay(attempt * 300);
125
+ continue;
126
+ }
127
+ saveAuth({ accessToken: json.accessToken, refreshToken: json.refreshToken, email: auth.email, expiresAt: new Date(exp * 1000).toISOString() });
98
128
  return;
99
129
  }
100
- const expiresAt = new Date(exp * 1000).toISOString();
101
- saveAuth({
102
- accessToken: json.accessToken,
103
- refreshToken: json.refreshToken,
104
- email: auth.email,
105
- expiresAt,
106
- });
107
- }
108
- catch {
109
- // Refresh failed - caller will see expired auth
130
+ // 401/403 the refresh token was rejected outright (it was rotated away by a
131
+ // sibling, or genuinely expired). Re-read once more in case a sibling's fresh
132
+ // token just landed; otherwise stop and let the caller's 401 path re-login.
133
+ if (res.status === 401 || res.status === 403) {
134
+ const after = readAuthFresh();
135
+ if (after && fresh(after)) {
136
+ cached = after;
137
+ return;
138
+ }
139
+ return;
140
+ }
141
+ await delay(attempt * 300); // 5xx / unexpected → transient, retry
110
142
  }
143
+ // Retries exhausted: leave the existing token. The caller's request will 401 and the
144
+ // existing handler messages the user — we never delete the shared auth.json here.
111
145
  }
112
146
  //# sourceMappingURL=auth.js.map
@@ -1,19 +1,48 @@
1
1
  import { Command } from 'commander';
2
2
  import { post } from '../api.js';
3
- import { resolveProjectContext } from '../config.js';
3
+ import { resolveProjectContext, getConfigPath } from '../config.js';
4
+ import { pushFile } from '../sync.js';
4
5
  import { writeFileSync } from 'fs';
5
- import { resolve as resolvePath } from 'path';
6
+ import { resolve as resolvePath, dirname, relative, isAbsolute } from 'path';
6
7
  import { error as clrError, success, muted, info } from '../colors.js';
7
8
  import { IMAGE_MODELS_DOC, IMAGE_GEMINI_ASPECT_RATIOS, IMAGE_GEMINI_SIZES, VIDEO_MODELS_DOC, TTS_PROVIDER_DESCRIPTIONS } from '../provider-docs.js';
8
- /** Download a URL and save to a local file. Returns the absolute path written,
9
- * so callers can report exactly where the file landed. */
9
+ /** Download a URL and save to a local file, then push it up to the project so
10
+ * the cloud (and anything that mirrors it) immediately matches local disk.
11
+ * Returns the absolute path written, so callers can report where it landed.
12
+ *
13
+ * The push matters because generated media is written straight to disk with
14
+ * writeFileSync, which does NOT trip the editor's file-sync hook the way an
15
+ * agent's own file write does. Without it the file is local-only until the
16
+ * next `gipity sync`, so `gipity sandbox run` - which mirrors the *server* -
17
+ * can't see a just-generated image to convert/optimize it. Pushing here closes
18
+ * that gap so the "sandbox auto-mirrors the project" contract holds right after
19
+ * generation. Best-effort: the local save already succeeded, so a push failure
20
+ * only warns and points at `gipity sync`. */
10
21
  async function downloadFile(url, filename) {
11
22
  const res = await fetch(url);
12
23
  if (!res.ok)
13
24
  throw new Error(`Download failed: ${res.status}`);
14
25
  const buffer = Buffer.from(await res.arrayBuffer());
15
26
  writeFileSync(filename, buffer);
16
- return resolvePath(filename);
27
+ const savedPath = resolvePath(filename);
28
+ await pushGenerated(savedPath);
29
+ return savedPath;
30
+ }
31
+ /** Sync a freshly generated file up to the linked project (no-op when there's
32
+ * no local project, or the file was written outside the project tree). */
33
+ async function pushGenerated(savedPath) {
34
+ const configPath = getConfigPath();
35
+ if (!configPath)
36
+ return; // not linked to a project - nothing to sync into
37
+ const rel = relative(dirname(configPath), savedPath);
38
+ if (rel.startsWith('..') || isAbsolute(rel))
39
+ return; // outside the project tree
40
+ try {
41
+ await pushFile(savedPath);
42
+ }
43
+ catch (err) {
44
+ console.error(muted(`Note: couldn't sync to the cloud automatically (${err.message}). Run \`gipity sync\` before referencing this file in \`gipity sandbox run\`.`));
45
+ }
17
46
  }
18
47
  // ── IMAGE ──────────────────────────────────────────────────────────────
19
48
  const imageCommand = new Command('image')
@@ -6,6 +6,13 @@ import { run } from '../helpers/index.js';
6
6
  import { capWaitMs } from './page-eval.js';
7
7
  /** A console line is an error-level entry (page error or console.error). */
8
8
  const isErrorLine = (line) => /^error:/i.test(line);
9
+ /** A message-less, cross-origin "Script error." The throwing <script> lacks
10
+ * CORS, so the browser strips its message/stack and the source is unknowable
11
+ * from the console alone — there is no own-code stack to chase. These can't be
12
+ * attributed to app code, so we surface them apart from real console errors
13
+ * rather than letting an unactionable (and sometimes growing) count read as a
14
+ * regression in the app the agent just wrote. */
15
+ const isMessagelessCrossOrigin = (line) => isErrorLine(line) && /message-less|cross-origin|Script error\.?/i.test(line);
9
16
  function shortUrl(url, truncate = true, maxLen = 100) {
10
17
  let result;
11
18
  try {
@@ -55,13 +62,21 @@ export const pageInspectCommand = new Command('inspect')
55
62
  };
56
63
  const res = await post(`/tools/browser/inspect`, inspectBody);
57
64
  const b = res.data;
58
- // Self-verify console errors before flagging them. A freshly-deployed page's
59
- // first hit can throw a one-time, non-reproducible error typically a
60
- // cross-origin "Script error." with no message/stack from a CDN asset still
61
- // propagatingand reporting it as a real defect sends agents chasing a
62
- // phantom. So when the first probe reports error-level console lines, re-probe
63
- // once (the sticky session is now warm) and keep only the errors that recur;
64
- // errors seen on a single probe are surfaced separately as transient noise.
65
+ // Pull message-less cross-origin "Script error." lines out first. They carry
66
+ // no source/stack, so they're never actionable as app-code defects, and on a
67
+ // Gipity-deployed page the platform's own injected SDK is itself a
68
+ // cross-origin script so these are reported separately (not as app console
69
+ // errors, and not folded into the re-probe count) instead of misleading the
70
+ // agent into chasing its own code.
71
+ const crossOriginErrors = (b.console || []).filter(isMessagelessCrossOrigin);
72
+ b.console = (b.console || []).filter((l) => !isMessagelessCrossOrigin(l));
73
+ // Self-verify the remaining console errors before flagging them. A
74
+ // freshly-deployed page's first hit can throw a one-time, non-reproducible
75
+ // error from an asset still propagating — and reporting it as a real defect
76
+ // sends agents chasing a phantom. So when the first probe reports error-level
77
+ // console lines, re-probe once (the sticky session is now warm) and keep only
78
+ // the errors that recur; errors seen on a single probe are surfaced
79
+ // separately as transient noise.
65
80
  let transientErrors = [];
66
81
  if ((b.console || []).some(isErrorLine)) {
67
82
  try {
@@ -76,7 +91,11 @@ export const pageInspectCommand = new Command('inspect')
76
91
  }
77
92
  }
78
93
  if (opts.json) {
79
- console.log(JSON.stringify(transientErrors.length ? { ...b, transientConsole: transientErrors } : b));
94
+ console.log(JSON.stringify({
95
+ ...b,
96
+ ...(transientErrors.length ? { transientConsole: transientErrors } : {}),
97
+ ...(crossOriginErrors.length ? { crossOriginConsole: crossOriginErrors } : {}),
98
+ }));
80
99
  return;
81
100
  }
82
101
  const timing = b.timing || { ttfb: 0, domReady: 0, load: 0 };
@@ -112,7 +131,12 @@ export const pageInspectCommand = new Command('inspect')
112
131
  for (const line of transientErrors) {
113
132
  console.log(muted(line));
114
133
  }
115
- console.log(muted('One-time cold-load artifact (first hit of freshly-deployed assets, or a cross-origin script) — not reproducible, not in your app code. Ignore unless it recurs.'));
134
+ console.log(muted('One-time cold-load artifact (first hit of freshly-deployed assets) — not reproducible, not in your app code. Ignore unless it recurs.'));
135
+ }
136
+ // ── Cross-origin console errors (message-less; source hidden by the browser) ──
137
+ if (crossOriginErrors.length > 0) {
138
+ console.log(`\n${bold('Cross-origin console errors')} ${muted(`(${crossOriginErrors.length}, source hidden by the browser)`)}:`);
139
+ console.log(muted("Message-less — the throwing <script> lacks CORS, so the browser hides its source and there's no own-code stack to chase. Gipity's injected SDK is itself cross-origin, so if your app loads no third-party CDN scripts these are platform noise — ignore them. If your app DOES load a third-party <script>, add crossorigin=\"anonymous\" to that tag to surface the real error."));
116
140
  }
117
141
  // ── Failed Resources ──
118
142
  // Browsers auto-request /favicon.ico at the site root for every page, so a
@@ -231,4 +231,25 @@ export const pageScreenshotCommand = new Command('screenshot')
231
231
  console.log(`${label('Screenshot file')} ${success(savedFiles[i])}`);
232
232
  }
233
233
  }));
234
+ // `screenshot` captures the page AS IT LOADS — there is no flag to scroll, click,
235
+ // run a script, or wait for a selector before capture (agents reach for --eval/
236
+ // --script/--scroll/--selector and get an unknown-option detour). State this
237
+ // limitation and the two supported alternatives right here, so the help (rendered
238
+ // on any bad flag, and this 'after' block survives `| tail`/`| grep`) ends the
239
+ // hunt in one shot instead of sending the agent grepping `--help` for
240
+ // scroll/script/before/action. The real per-element capture lives server-side and
241
+ // isn't wired yet — until then, --full + crop or `page eval` cover the need.
242
+ pageScreenshotCommand.addHelpText('after', `
243
+ Examples:
244
+ gipity page screenshot "https://dev.gipity.ai/me/app/"
245
+ gipity page screenshot "https://dev.gipity.ai/me/app/" --full # whole scrollable page
246
+ gipity page screenshot "https://dev.gipity.ai/me/app/" --device mobile,desktop
247
+
248
+ Capturing a specific state (a slide, an off-screen element, a post-scroll view)?
249
+ screenshot captures the page as it loads — it does NOT scroll, click, wait for a
250
+ selector, or run a script before capture. To get the part you want:
251
+ • --full captures the ENTIRE scrollable page (then crop to the region).
252
+ • 'gipity page eval <url> "<expr>"' reads any (even off-screen) element's
253
+ data/state/rect without a picture — e.g. read the chart's bar values
254
+ directly instead of screenshotting the slide.`);
234
255
  //# sourceMappingURL=page-screenshot.js.map
@@ -0,0 +1,65 @@
1
+ import { Command } from 'commander';
2
+ import { get, post, del } from '../api.js';
3
+ import { bold, muted, success, warning } from '../colors.js';
4
+ import { run, printList } from '../helpers/index.js';
5
+ export const tokenCommand = new Command('token')
6
+ .description('Manage agent API tokens (gip_at_*) for headless agents and CI');
7
+ const fmtDate = (d) => (d ? new Date(d).toLocaleDateString() : 'never');
8
+ tokenCommand
9
+ .command('create')
10
+ .description('Mint a long-lived agent API token (shown once)')
11
+ .option('--name <name>', 'Label for the token, e.g. "Hermes on my VPS"')
12
+ .option('--expires <days>', 'Days until the token expires (default: never)')
13
+ .option('--json', 'Output as JSON')
14
+ .action((opts) => run('Create', async () => {
15
+ const body = {};
16
+ if (opts.name)
17
+ body.name = opts.name;
18
+ if (opts.expires !== undefined) {
19
+ const days = parseInt(opts.expires, 10);
20
+ if (!Number.isFinite(days) || days <= 0)
21
+ throw new Error('--expires must be a positive number of days');
22
+ body.expiresInDays = days;
23
+ }
24
+ const res = await post('/auth/agent-tokens', body);
25
+ const { token, shortGuid, expiresAt } = res.data;
26
+ if (opts.json) {
27
+ console.log(JSON.stringify(res.data));
28
+ return;
29
+ }
30
+ const expNote = expiresAt ? ` (expires ${fmtDate(expiresAt)})` : ' (never expires)';
31
+ console.log(success(`Created token ${bold(shortGuid)}${muted(expNote)}.`));
32
+ console.log('');
33
+ console.log(token);
34
+ console.log('');
35
+ console.log(muted('Use it from an agent, script, or CI — no login needed:'));
36
+ console.log(` export GIPITY_TOKEN=${token}`);
37
+ console.log('');
38
+ console.log(warning('Copy it now — it will not be shown again.'));
39
+ }));
40
+ tokenCommand
41
+ .command('list')
42
+ .alias('ls')
43
+ .description('List your active agent API tokens')
44
+ .option('--json', 'Output as JSON')
45
+ .action((opts) => run('List', async () => {
46
+ const res = await get('/auth/agent-tokens');
47
+ printList(res.data, opts, 'No agent API tokens.', (t) => {
48
+ const label = t.name ? ` ${t.name}` : '';
49
+ return `${bold(t.short_guid)}${label} ${muted(`created ${fmtDate(t.created_at)}`)} ${muted(`expires ${fmtDate(t.expires_at)}`)} ${muted(`last used ${fmtDate(t.last_used_at)}`)}`;
50
+ });
51
+ }));
52
+ tokenCommand
53
+ .command('revoke <short_guid>')
54
+ .alias('rm')
55
+ .description('Revoke an agent API token (instant, irreversible)')
56
+ .option('--json', 'Output as JSON')
57
+ .action((shortGuid, opts) => run('Revoke', async () => {
58
+ await del(`/auth/agent-tokens/${encodeURIComponent(shortGuid)}`);
59
+ if (opts.json) {
60
+ console.log(JSON.stringify({ shortGuid, revoked: true }));
61
+ return;
62
+ }
63
+ console.log(success(`Revoked token ${bold(shortGuid)}.`));
64
+ }));
65
+ //# sourceMappingURL=token.js.map
package/dist/config.js CHANGED
@@ -45,6 +45,12 @@ export function resolveApiBase() {
45
45
  const override = getApiBaseOverride();
46
46
  if (override)
47
47
  return override;
48
+ // GIPITY_API_BASE env is a trusted override (any host, like --api-base) so a
49
+ // caller can point the CLI at a local dev server without passing the flag on
50
+ // every command — e.g. GipRunner running builds against http://localhost:7201.
51
+ const fromEnv = process.env.GIPITY_API_BASE;
52
+ if (fromEnv)
53
+ return fromEnv;
48
54
  const fromConfig = getConfig()?.apiBase;
49
55
  if (fromConfig) {
50
56
  if (isAllowedApiHost(fromConfig))
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { GIPITY_TAGLINE } from './knowledge.js';
10
10
  import { getAuth, sessionExpired } from './auth.js';
11
11
  import { loginCommand } from './commands/login.js';
12
12
  import { logoutCommand } from './commands/logout.js';
13
+ import { tokenCommand } from './commands/token.js';
13
14
  import { initCommand } from './commands/init.js';
14
15
  import { statusCommand } from './commands/status.js';
15
16
  import { syncCommand } from './commands/sync.js';
@@ -108,7 +109,7 @@ const filesGroup = [fileCommand, syncCommand, pushCommand, uploadCommand];
108
109
  const appBuildingGroup = [testCommand, fnCommand, serviceCommand, jobCommand, dbCommand, logsCommand, workflowCommand, realtimeCommand, rbacCommand, auditCommand, recordsCommand];
109
110
  const utilitiesGroup = [pageCommand, sandboxCommand, generateCommand, emailCommand, gmailCommand, locationCommand, textCommand];
110
111
  const agentGroup = [chatCommand, memoryCommand, agentCommand, approvalCommand];
111
- const setupGroup = [loginCommand, logoutCommand, creditsCommand, planCommand, doctorCommand, updateCommand, uninstallCommand];
112
+ const setupGroup = [loginCommand, logoutCommand, tokenCommand, creditsCommand, planCommand, doctorCommand, updateCommand, uninstallCommand];
112
113
  const HELP_SECTIONS = [
113
114
  { title: 'Common', cmds: commonGroup },
114
115
  { title: 'Connect', cmds: connectGroup },
@@ -125,11 +126,15 @@ program
125
126
  .version(pkg.version, '-v, --version')
126
127
  .addOption(new Option('--api-base <url>', 'API base URL').hideHelp())
127
128
  .option('-y, --yes', 'Skip confirmation prompts');
128
- program.hook('preAction', () => {
129
+ program.hook('preAction', (_thisCommand, actionCommand) => {
129
130
  const globalOpts = program.opts();
130
131
  if (globalOpts.apiBase)
131
132
  setApiBaseOverride(globalOpts.apiBase);
132
- if (globalOpts.yes)
133
+ // Honor `-y`/`--yes` whether it came before the subcommand (the global flag)
134
+ // or after it (the per-command flag registered by enableYesEverywhere below),
135
+ // so both `gipity -y records delete ...` and `gipity records delete ... --yes`
136
+ // skip confirmation identically.
137
+ if (globalOpts.yes || actionCommand.opts().yes)
133
138
  setAutoConfirm(true);
134
139
  });
135
140
  // Bracket non-JSON command output with leading/trailing blank lines centrally,
@@ -216,6 +221,25 @@ function enableHelpAfterError(cmd) {
216
221
  enableHelpAfterError(sub);
217
222
  }
218
223
  enableHelpAfterError(program);
224
+ // ── `-y`/`--yes` accepted AFTER any subcommand, not only before it ──────
225
+ // The global `-y` lives on `program`, so Commander parses it only when it
226
+ // precedes the subcommand (`gipity -y records delete ...`). Agents and humans
227
+ // instinctively append it instead (`gipity records delete ... --yes`), which
228
+ // Commander would reject as an unknown option and dump help for. Register the
229
+ // flag on every leaf command so both positions work identically; the preAction
230
+ // hook honors whichever one was set. Skip commands that already declare their
231
+ // own `--yes` (e.g. `fn delete`, `db drop`, `remove`) to avoid a duplicate.
232
+ function enableYesEverywhere(cmd) {
233
+ if (cmd.commands.length > 0) {
234
+ for (const sub of cmd.commands)
235
+ enableYesEverywhere(sub);
236
+ return;
237
+ }
238
+ const hasYes = cmd.options.some(o => o.long === '--yes' || o.short === '-y');
239
+ if (!hasYes)
240
+ cmd.addOption(new Option('-y, --yes', 'Skip confirmation prompts').hideHelp());
241
+ }
242
+ enableYesEverywhere(program);
219
243
  // Auto-fetch related skill docs when --help is run on a doc-bearing TOP-LEVEL
220
244
  // command (e.g. `gipity fn --help`, `gipity db --help`). It must NOT fire for a
221
245
  // subcommand's help: `gipity db query --help` should render commander's own
package/dist/knowledge.js CHANGED
@@ -122,6 +122,7 @@ App services skills (load before calling \`/services/*\` endpoints):
122
122
  - \`app-video\` - Gipity Video: models, aspect, resolution
123
123
 
124
124
  App development skills:
125
+ - \`agent-deploy\` - headless auth via agent API tokens (GIPITY_TOKEN) for unattended deploys
125
126
  - \`app-debugging\` - debug a deployed app: page inspect/eval, screenshots, function logs
126
127
  - \`app-development\` - functions, database, and API
127
128
  - \`deploy\` - the deploy pipeline & gipity.yaml manifest
@@ -718,7 +718,13 @@ async function handleDispatch(d) {
718
718
  // Future cleanup: see docs/feature-backlog/future-generate-to-vfs.md
719
719
  // - server-side /generate/* should write directly to VFS and make
720
720
  // this sync redundant for that case.
721
- if (!spawnErr) {
721
+ //
722
+ // Skip on `killed`: a kill-on-new-message replacement is already starting for
723
+ // this conv, and a bidirectional reconcile over the half-finished tree of the
724
+ // cancelled run is exactly the WS-00172 stale-state trap - it pushes/pulls a
725
+ // partial state that the resuming run then fights. The replacement dispatch
726
+ // runs its own sync; let it own the tree.
727
+ if (!spawnErr && !killed) {
722
728
  try {
723
729
  await spawnSync(cwd, PROJECT_SYNC_TIMEOUT_MS);
724
730
  }
package/dist/sync.js CHANGED
@@ -1030,9 +1030,18 @@ export async function pushFile(filePath) {
1030
1030
  const rel = relative(root, filePath).replace(/\\/g, '/');
1031
1031
  if (shouldIgnore(rel, effectiveIgnore(root, config.ignore)))
1032
1032
  return;
1033
- const baseline = readBaseline(config.projectGuid);
1034
- const baseEntry = baseline.files[rel];
1033
+ // Serialize against `gipity sync` and other concurrent pushes by holding the
1034
+ // same per-project lock `sync()` uses. Both paths read-modify-write the shared
1035
+ // baseline; without a common lock, a burst of PostToolUse pushes (each a
1036
+ // detached `gipity push`) racing the UserPromptSubmit/post-dispatch reconciles
1037
+ // drops baseline updates, and the 3-way merge then misreads our own just-pushed
1038
+ // edits as `modified×modified` conflicts (or pulls stale bytes over a live
1039
+ // edit). Read the baseline AFTER acquiring the lock so earlier pushes' writes
1040
+ // are visible. (WS-00172)
1041
+ const releaseLock = await acquireLock();
1035
1042
  try {
1043
+ const baseline = readBaseline(config.projectGuid);
1044
+ const baseEntry = baseline.files[rel];
1036
1045
  const result = await uploadOneFile(config.projectGuid, filePath, rel, {
1037
1046
  expectedServerVersion: baseEntry ? baseEntry.serverVersion : null,
1038
1047
  });
@@ -1051,5 +1060,8 @@ export async function pushFile(filePath) {
1051
1060
  }
1052
1061
  throw err;
1053
1062
  }
1063
+ finally {
1064
+ releaseLock();
1065
+ }
1054
1066
  }
1055
1067
  //# sourceMappingURL=sync.js.map
package/dist/utils.js CHANGED
@@ -47,6 +47,15 @@ export async function promptBoxed() {
47
47
  let _autoConfirm = false;
48
48
  export function setAutoConfirm(val) { _autoConfirm = val; }
49
49
  export function getAutoConfirm() { return _autoConfirm; }
50
+ /** Reconstruct the current invocation with `--yes` appended, for self-correcting
51
+ * non-interactive confirmation hints. Shell-quotes args that need it. */
52
+ function rerunWithYes() {
53
+ const args = process.argv.slice(2);
54
+ if (!args.some(a => a === '--yes' || a === '-y'))
55
+ args.push('--yes');
56
+ const quote = (a) => (/[^\w@%+=:,./-]/.test(a) ? `'${a.replace(/'/g, `'\\''`)}'` : a);
57
+ return `gipity ${args.map(quote).join(' ')}`;
58
+ }
50
59
  /** Ask for Y/n confirmation. Single-keypress - no Enter required.
51
60
  *
52
61
  * - `opts.default` controls which answer Enter / unknown-key selects. Defaults to `'no'`.
@@ -59,7 +68,10 @@ export async function confirm(question, opts = {}) {
59
68
  if (opts.skip ?? _autoConfirm)
60
69
  return true;
61
70
  if (!process.stdin.isTTY) {
62
- console.error('Confirmation required. Use --yes to skip prompts.');
71
+ // Headless/agent context: no one can answer the prompt. Don't just say
72
+ // "use --yes" - echo the exact command to re-run so the fix is copy-paste,
73
+ // not a second guessing trip.
74
+ console.error(`Confirmation required (non-interactive). Re-run with --yes:\n ${rerunWithYes()}`);
63
75
  return false;
64
76
  }
65
77
  const hint = defaultYes ? dim('[Y/n]') : dim('[y/N]');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gipity",
3
- "version": "1.0.391",
3
+ "version": "1.0.392",
4
4
  "description": "The full-stack platform tuned for AI agents. Database, storage, auth, functions, deploy, and drop-in kits - all agent-tuned. Pair with Claude Code or use standalone.",
5
5
  "bin": {
6
6
  "gipity": "dist/updater/shim.js",
@@ -12,7 +12,7 @@
12
12
  "build": "tsc && chmod +x dist/index.js dist/gipcc.js dist/gipccd.js dist/updater/shim.js dist/updater/check.js",
13
13
  "dev": "tsc --watch",
14
14
  "test": "npm run test:smoke",
15
- "test:smoke": "tsc && node --test dist/__tests__/utils.test.js dist/__tests__/config.test.js dist/__tests__/sync.test.js dist/__tests__/sync-apply.test.js dist/__tests__/sync-lock.test.js dist/__tests__/push-cas.test.js dist/__tests__/upload.test.js dist/__tests__/progress.test.js dist/__tests__/updater.test.js dist/__tests__/cli-smoke.test.js dist/__tests__/claude-noninteractive.test.js dist/__tests__/claude-trust.test.js dist/__tests__/relay-state.test.js dist/__tests__/relay-daemon.test.js dist/__tests__/relay-installers.test.js dist/__tests__/relay-bridge-abort.test.js dist/__tests__/relay-redact.test.js dist/__tests__/relay-machine-id.test.js dist/__tests__/stream-json.test.js dist/__tests__/relay-ingest-contract.test.js dist/__tests__/prompts.test.js dist/__tests__/capture-transcript.test.js dist/__tests__/flag-aliases.test.js dist/__tests__/adopt-cwd.test.js dist/__tests__/cli-cmd-agent.test.js dist/__tests__/cli-cmd-approval.test.js dist/__tests__/cli-cmd-audit.test.js dist/__tests__/cli-cmd-chat.test.js dist/__tests__/cli-cmd-credits.test.js dist/__tests__/cli-cmd-db.test.js dist/__tests__/cli-cmd-deploy.test.js dist/__tests__/cli-cmd-domain.test.js dist/__tests__/cli-cmd-email.test.js dist/__tests__/cli-cmd-file.test.js dist/__tests__/cli-cmd-fn.test.js dist/__tests__/cli-cmd-service.test.js dist/__tests__/cli-cmd-job.test.js dist/__tests__/cli-cmd-generate.test.js dist/__tests__/cli-cmd-gmail.test.js dist/__tests__/cli-cmd-info.test.js dist/__tests__/cli-cmd-init.test.js dist/__tests__/cli-cmd-location.test.js dist/__tests__/cli-cmd-text.test.js dist/__tests__/cli-cmd-login.test.js dist/__tests__/cli-cmd-logout.test.js dist/__tests__/cli-cmd-logs.test.js dist/__tests__/cli-cmd-memory.test.js dist/__tests__/cli-cmd-page.test.js dist/__tests__/cli-cmd-plan.test.js dist/__tests__/cli-cmd-project.test.js dist/__tests__/cli-cmd-rbac.test.js dist/__tests__/cli-cmd-realtime.test.js dist/__tests__/cli-cmd-records.test.js dist/__tests__/cli-cmd-relay.test.js dist/__tests__/cli-cmd-sandbox.test.js dist/__tests__/cli-cmd-add.test.js dist/__tests__/cli-cmd-remove.test.js dist/__tests__/cli-cmd-skill.test.js dist/__tests__/cli-cmd-test.test.js dist/__tests__/cli-cmd-workflow.test.js dist/__tests__/setup-skills-block.test.js dist/__tests__/setup-hooks.test.js",
15
+ "test:smoke": "tsc && node --test dist/__tests__/utils.test.js dist/__tests__/config.test.js dist/__tests__/sync.test.js dist/__tests__/sync-apply.test.js dist/__tests__/sync-lock.test.js dist/__tests__/push-cas.test.js dist/__tests__/upload.test.js dist/__tests__/progress.test.js dist/__tests__/updater.test.js dist/__tests__/cli-smoke.test.js dist/__tests__/claude-noninteractive.test.js dist/__tests__/claude-trust.test.js dist/__tests__/relay-state.test.js dist/__tests__/relay-daemon.test.js dist/__tests__/relay-installers.test.js dist/__tests__/relay-bridge-abort.test.js dist/__tests__/relay-redact.test.js dist/__tests__/relay-machine-id.test.js dist/__tests__/stream-json.test.js dist/__tests__/relay-ingest-contract.test.js dist/__tests__/prompts.test.js dist/__tests__/capture-transcript.test.js dist/__tests__/flag-aliases.test.js dist/__tests__/adopt-cwd.test.js dist/__tests__/cli-cmd-agent.test.js dist/__tests__/cli-cmd-approval.test.js dist/__tests__/cli-cmd-audit.test.js dist/__tests__/cli-cmd-chat.test.js dist/__tests__/cli-cmd-credits.test.js dist/__tests__/cli-cmd-db.test.js dist/__tests__/cli-cmd-deploy.test.js dist/__tests__/cli-cmd-domain.test.js dist/__tests__/cli-cmd-email.test.js dist/__tests__/cli-cmd-file.test.js dist/__tests__/cli-cmd-fn.test.js dist/__tests__/cli-cmd-service.test.js dist/__tests__/cli-cmd-job.test.js dist/__tests__/cli-cmd-generate.test.js dist/__tests__/cli-cmd-gmail.test.js dist/__tests__/cli-cmd-info.test.js dist/__tests__/cli-cmd-init.test.js dist/__tests__/cli-cmd-location.test.js dist/__tests__/cli-cmd-text.test.js dist/__tests__/cli-cmd-login.test.js dist/__tests__/cli-cmd-logout.test.js dist/__tests__/cli-cmd-token.test.js dist/__tests__/cli-cmd-logs.test.js dist/__tests__/cli-cmd-memory.test.js dist/__tests__/cli-cmd-page.test.js dist/__tests__/cli-cmd-plan.test.js dist/__tests__/cli-cmd-project.test.js dist/__tests__/cli-cmd-rbac.test.js dist/__tests__/cli-cmd-realtime.test.js dist/__tests__/cli-cmd-records.test.js dist/__tests__/cli-cmd-relay.test.js dist/__tests__/cli-cmd-sandbox.test.js dist/__tests__/cli-cmd-add.test.js dist/__tests__/cli-cmd-remove.test.js dist/__tests__/cli-cmd-skill.test.js dist/__tests__/cli-cmd-test.test.js dist/__tests__/cli-cmd-workflow.test.js dist/__tests__/setup-skills-block.test.js dist/__tests__/setup-hooks.test.js",
16
16
  "test:e2e": "tsc && GIPITY_E2E=1 node --test --test-timeout=180000 dist/__tests__/cli-e2e-live.test.js dist/__tests__/cli-e2e-services-media-live.test.js dist/__tests__/cli-e2e-workflow-live.test.js dist/__tests__/cli-e2e-sandbox-live.test.js dist/__tests__/cli-e2e-page-fetch-live.test.js dist/__tests__/cli-e2e-page-test-live.test.js",
17
17
  "test:e2e:sandbox": "tsc && GIPITY_E2E=1 node --test --test-timeout=180000 dist/__tests__/cli-e2e-sandbox-live.test.js"
18
18
  },