gipity 1.1.5 → 1.1.7

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/index.js CHANGED
@@ -4002,9 +4002,10 @@ async function confirm(question, opts = {}) {
4002
4002
  const defaultYes = opts.default === "yes";
4003
4003
  if (opts.skip ?? _autoConfirm) return true;
4004
4004
  if (!process.stdin.isTTY) {
4005
+ if (opts.headless === "no") return false;
4005
4006
  console.error(`Confirmation required (non-interactive). Re-run with --yes:
4006
4007
  ${rerunWithYes()}`);
4007
- return false;
4008
+ process.exit(1);
4008
4009
  }
4009
4010
  const hint = defaultYes ? dim("[Y/n]") : dim("[y/N]");
4010
4011
  process.stdout.write(`${question} ${hint}: `);
@@ -6597,6 +6598,9 @@ function detectHarness() {
6597
6598
  }
6598
6599
  if (hasEnvPrefix("CODEX_")) return { harness: "codex" };
6599
6600
  if (hasEnvPrefix("GROK_")) return { harness: "grok", harnessSession: process.env.GROK_SESSION_ID };
6601
+ if (process.env.ANTIGRAVITY_CONVERSATION_ID) {
6602
+ return { harness: "agy", harnessSession: process.env.ANTIGRAVITY_CONVERSATION_ID };
6603
+ }
6600
6604
  if (process.env.CURSOR_TRACE_ID || hasEnvPrefix("CURSOR_") || (process.env.TERM_PROGRAM ?? "").toLowerCase().includes("cursor")) {
6601
6605
  return { harness: "cursor" };
6602
6606
  }
@@ -6643,11 +6647,13 @@ __export(api_exports, {
6643
6647
  getAccountSlug: () => getAccountSlug,
6644
6648
  getAuthHeader: () => getAuthHeader,
6645
6649
  getBaseUrl: () => getBaseUrl,
6650
+ mintAppToken: () => mintAppToken,
6646
6651
  patch: () => patch,
6647
6652
  post: () => post,
6648
6653
  postBinary: () => postBinary,
6649
6654
  postForTarEntries: () => postForTarEntries,
6650
6655
  publicPost: () => publicPost,
6656
+ publicRequest: () => publicRequest,
6651
6657
  put: () => put,
6652
6658
  putTimeoutMs: () => putTimeoutMs,
6653
6659
  putToPresignedUrl: () => putToPresignedUrl,
@@ -6903,12 +6909,12 @@ async function getAccountSlug() {
6903
6909
  accountSlugCache = res.data.accountSlug;
6904
6910
  return accountSlugCache;
6905
6911
  }
6906
- async function publicPost(path5, body, extraHeaders) {
6912
+ async function publicRequest(method, path5, body, extraHeaders) {
6907
6913
  const url = `${baseUrl()}${path5}`;
6908
6914
  const res = await fetch(url, {
6909
- method: "POST",
6915
+ method,
6910
6916
  headers: { ...clientHeaders(), "Content-Type": "application/json", ...extraHeaders },
6911
- body: JSON.stringify(body)
6917
+ body: body === void 0 ? void 0 : JSON.stringify(body)
6912
6918
  });
6913
6919
  if (!res.ok) {
6914
6920
  const json = await res.json().catch(() => ({ error: { code: "UNKNOWN", message: res.statusText } }));
@@ -6917,6 +6923,17 @@ async function publicPost(path5, body, extraHeaders) {
6917
6923
  }
6918
6924
  return res.json();
6919
6925
  }
6926
+ function publicPost(path5, body, extraHeaders) {
6927
+ return publicRequest("POST", path5, body, extraHeaders);
6928
+ }
6929
+ async function mintAppToken(projectGuid2) {
6930
+ try {
6931
+ const minted = await publicPost("/api/token", { app: projectGuid2 });
6932
+ return { "X-App-Token": minted.data.token };
6933
+ } catch {
6934
+ return void 0;
6935
+ }
6936
+ }
6920
6937
  var tar, ApiError, REQUEST_TIMEOUT_MS, DOWNLOAD_HEADER_TIMEOUT_MS, PUT_TIMEOUT_FLOOR_MS, PUT_MIN_BYTES_PER_SEC, accountSlugCache;
6921
6938
  var init_api = __esm({
6922
6939
  "src/api.ts"() {
@@ -32075,7 +32092,7 @@ mkdir -p ~/GipityProjects/<slug> && cd ~/GipityProjects/<slug> && gipity init <s
32075
32092
 
32076
32093
  ## CLI quick reference
32077
32094
 
32078
- Key commands: \`gipity add <template|kit>\`, \`gipity deploy dev\`, \`gipity sandbox run\`, \`gipity page inspect <url>\`, \`gipity page screenshot <url>\`, \`gipity db query "SQL"\`, \`gipity fn call <name>\`, \`gipity logs fn <name>\`, \`gipity secrets set <NAME> <value> [--account]\` (store an API key/token encrypted; read in functions via \`secrets.get('NAME')\` \u2014 never hardcode keys), \`gipity email send --to <addr> --subject <s> --body <b>\` (sends as \`gipity@gipity.ai\`; omit \`--to\` to self-send), \`gipity skill read <name>\`.
32095
+ Key commands: \`gipity add <template|kit>\`, \`gipity brand set --emoji <e>|--color <hex>\` (regenerate the app's icons + social share card), \`gipity deploy dev\`, \`gipity sandbox run\`, \`gipity page inspect <url>\`, \`gipity page screenshot <url>\`, \`gipity db query "SQL"\`, \`gipity fn call <name>\`, \`gipity logs fn <name>\`, \`gipity secrets set <NAME> <value> [--account]\` (store an API key/token encrypted; read in functions via \`secrets.get('NAME')\` \u2014 never hardcode keys), \`gipity email send --to <addr> --subject <s> --body <b>\` (sends as \`gipity@gipity.ai\`; omit \`--to\` to self-send), \`gipity skill read <name>\`.
32079
32096
  Rename for findability: \`gipity project rename <name>\` renames the current project's display name (the slug and deployed URLs never change); \`gipity chat rename <title>\` renames the current chat's tab title. Both are the display label users scan to switch between tabs \u2014 retitle a chat when the conversation clearly shifts to a new topic (sparingly, not every turn), and keep every project/chat title SHORT: 2-4 words, \u226440 characters, no trailing punctuation (e.g. "Stripe checkout", "Tetris game").
32080
32097
  Pull an existing remote project local (given its URL/slug): \`mkdir -p ~/GipityProjects/<slug> && cd ~/GipityProjects/<slug> && gipity init <slug>\` (adopts the matching project and syncs files down - this is the "clone").
32081
32098
  Move whole apps in/out: \`gipity save\` (export this project as a portable \`.gip\` bundle), \`gipity load <file.gip | github:owner/repo>\` (import as a NEW project; \`--inspect\` to preview), \`gipity github connect\` (1-2 click GitHub access for imports). Porting a Vercel/Replit/Lovable app? Load the \`app-import\` skill first.
@@ -32085,7 +32102,7 @@ Run \`gipity --help\` for the full list. Use \`--help\` on any command for detai
32085
32102
 
32086
32103
  Function return shape: \`gipity fn call\`, the in-test \`ctx.fn.call\`/\`callAs\`, and the client \`Gipity.fn\` all return your function's value **unwrapped** - read/assert \`result.field\`. Only raw HTTP/\`curl\` wraps it as \`{ data: ... }\`; never write \`result.data.field\` in a test.
32087
32104
 
32088
- Tests are isolated, not run against your live DB: \`gipity test\` points \`ctx.fn.call\`/\`callAs\` at a throwaway copy of your database (your \`migrations/\` + \`seeds/\`), reset (truncate + reseed) before every run - so test rows never reach the deployed app and you don't write teardown. Functions see \`ctx.isTest === true\` during a run (use it to skip your own rate limiting); the platform also suppresses \`notify()\` push so a suite can't spam subscribers. Reference data tests need goes in seed files; a runtime-written settings table that isn't seeded goes under \`test.preserve\` in \`gipity.yaml\`. Build per-file fixtures in \`setup(fn)\` \u2192 \`ctx.fixtures\`, and namespace unique values with \`ctx.testId\`.
32105
+ Tests are isolated, not run against your live DB: \`gipity test\` points \`ctx.fn.call\`/\`callAs\` at a throwaway copy of your database (your \`migrations/\` + \`seeds/\`), one per test FILE, reset (truncate + reseed) before the file runs - so files can't interfere, test rows never reach the deployed app, and you don't write teardown. Functions see \`ctx.isTest === true\` during a run (use it to skip your own rate limiting); the platform also suppresses \`notify()\` push so a suite can't spam subscribers. Reference data tests need goes in seed files; a runtime-written settings table that isn't seeded goes under \`test.preserve\` in \`gipity.yaml\`. Build per-file fixtures in \`setup(fn)\` \u2192 \`ctx.fixtures\`.
32089
32106
 
32090
32107
  ## Hit friction on the platform? Report it in real time
32091
32108
 
@@ -32156,7 +32173,7 @@ App development skills:
32156
32173
  - \`app-development\` - functions, database, and API
32157
32174
  - \`app-import\` - import apps from GitHub/.gip bundles (incl. Vercel/Replit/Lovable porting) and export any project as a portable .gip - app_import tool, gipity save/load
32158
32175
  - \`app-records\` - Gipity Records: declared tables \u2192 validated CRUD, provenance, workflows, auto UI
32159
- - \`app-testing\` - testing deployed app functions (ctx.fn.call/callAs, the isolated test DB)
32176
+ - \`app-testing\` - testing deployed app functions (ctx.fn.call/callAs, the per-file test DB)
32160
32177
  - \`deploy\` - the deploy pipeline & gipity.yaml manifest
32161
32178
  - \`jobs\` - long-running CPU + GPU compute jobs (Python / Node / bash)
32162
32179
  - \`realtime-scheduled-app\` - recipe: realtime presence/messages + DB function + scheduled poster, end-to-end
@@ -32513,7 +32530,7 @@ var UploadConflictError = class extends Error {
32513
32530
  currentServerVersion;
32514
32531
  path;
32515
32532
  };
32516
- async function uploadOneFile(projectGuid, localPath, virtualPath, opts = {}) {
32533
+ async function uploadOneFile(projectGuid2, localPath, virtualPath, opts = {}) {
32517
32534
  const { sha256, size } = await hashFile(localPath);
32518
32535
  const mime = opts.mime ?? guessMime(virtualPath);
32519
32536
  const initBody = { path: virtualPath, size, sha256, mime };
@@ -32523,7 +32540,7 @@ async function uploadOneFile(projectGuid, localPath, virtualPath, opts = {}) {
32523
32540
  let init;
32524
32541
  try {
32525
32542
  init = await post(
32526
- `/projects/${projectGuid}/files/upload-init`,
32543
+ `/projects/${projectGuid2}/files/upload-init`,
32527
32544
  initBody
32528
32545
  );
32529
32546
  } catch (err) {
@@ -32545,7 +32562,7 @@ async function uploadOneFile(projectGuid, localPath, virtualPath, opts = {}) {
32545
32562
  }
32546
32563
  let comp;
32547
32564
  try {
32548
- comp = await post(`/projects/${projectGuid}/files/upload-complete`, completeBody);
32565
+ comp = await post(`/projects/${projectGuid2}/files/upload-complete`, completeBody);
32549
32566
  } catch (err) {
32550
32567
  if (err instanceof ApiError && err.statusCode === 409) {
32551
32568
  const current = typeof err.data?.current_server_version === "number" ? err.data.current_server_version : null;
@@ -32627,16 +32644,16 @@ async function transferToS3(localPath, size, mime, data, opts = {}) {
32627
32644
  completed.sort((a, b7) => a.part_number - b7.part_number);
32628
32645
  return { parts: completed };
32629
32646
  }
32630
- async function uploadInitBatch(projectGuid, files) {
32647
+ async function uploadInitBatch(projectGuid2, files) {
32631
32648
  const res = await post(
32632
- `/projects/${projectGuid}/files/upload-init-batch`,
32649
+ `/projects/${projectGuid2}/files/upload-init-batch`,
32633
32650
  { files }
32634
32651
  );
32635
32652
  return res.data.results;
32636
32653
  }
32637
- async function uploadCompleteBatch(projectGuid, items) {
32654
+ async function uploadCompleteBatch(projectGuid2, items) {
32638
32655
  const res = await post(
32639
- `/projects/${projectGuid}/files/upload-complete-batch`,
32656
+ `/projects/${projectGuid2}/files/upload-complete-batch`,
32640
32657
  { items }
32641
32658
  );
32642
32659
  return res.data.results;
@@ -32653,6 +32670,8 @@ var PRIMER_FILES = {
32653
32670
  codex: "AGENTS.md",
32654
32671
  grok: "AGENTS.md",
32655
32672
  // Grok Build reads the AGENTS.md family (and CLAUDE.md) natively
32673
+ agy: "AGENTS.md",
32674
+ // Antigravity reads the same AGENTS.md/GEMINI.md rules family natively
32656
32675
  aider: "AGENTS.md",
32657
32676
  // shares the Codex primer; aider is pointed at it via .aider.conf.yml
32658
32677
  gemini: "GEMINI.md",
@@ -32668,6 +32687,7 @@ var DEFAULT_SYNC_IGNORE = [
32668
32687
  ".gipity/",
32669
32688
  ".claude/",
32670
32689
  ".codex/",
32690
+ ".agents/",
32671
32691
  ".gitignore",
32672
32692
  AIDER_CONF_FILE,
32673
32693
  // Home-directory junk: a project created inside a real home dir (or one that
@@ -32867,9 +32887,11 @@ function ensureGrokPluginInstalled() {
32867
32887
  var AGENTS_SKILLS_DIR = join4(homedir3(), ".agents", "skills");
32868
32888
  var AGENT_HOOKS_DIR = join4(homedir3(), ".gipity", "agent-hooks");
32869
32889
  var AGENT_SKILLS_MANIFEST = join4(homedir3(), ".gipity", "agent-skills.json");
32870
- function agentSkillsState() {
32890
+ var AGY_SKILLS_DIR = join4(homedir3(), ".gemini", "config", "skills");
32891
+ var AGY_SKILLS_MANIFEST = join4(homedir3(), ".gipity", "agy-skills.json");
32892
+ function skillsManifestState(manifestPath) {
32871
32893
  try {
32872
- const m = JSON.parse(readFileSync6(AGENT_SKILLS_MANIFEST, "utf-8"));
32894
+ const m = JSON.parse(readFileSync6(manifestPath, "utf-8"));
32873
32895
  return {
32874
32896
  current: typeof m?.version === "string" && versionGte(m.version, GIPITY_PLUGIN_VERSION),
32875
32897
  skills: Array.isArray(m?.skills) ? m.skills : []
@@ -32878,8 +32900,13 @@ function agentSkillsState() {
32878
32900
  return { current: false, skills: [] };
32879
32901
  }
32880
32902
  }
32881
- function ensureAgentSkillsInstalled() {
32882
- if (agentSkillsState().current) return;
32903
+ function agentSkillsState() {
32904
+ return skillsManifestState(AGENT_SKILLS_MANIFEST);
32905
+ }
32906
+ function agySkillsState() {
32907
+ return skillsManifestState(AGY_SKILLS_MANIFEST);
32908
+ }
32909
+ function installSkillsAndHooks(skillsDir, manifestPath, harnessLabel) {
32883
32910
  if (!binaryOnPath("git")) return;
32884
32911
  const tmp = mkdtempSync(join4(tmpdir(), "gipity-skills-"));
32885
32912
  try {
@@ -32901,8 +32928,8 @@ function ensureAgentSkillsInstalled() {
32901
32928
  for (const entry of readdirSync3(skillsSrc, { withFileTypes: true })) {
32902
32929
  if (!entry.isDirectory()) continue;
32903
32930
  if (!existsSync4(join4(skillsSrc, entry.name, "SKILL.md"))) continue;
32904
- mkdirSync3(AGENTS_SKILLS_DIR, { recursive: true });
32905
- cpSync(join4(skillsSrc, entry.name), join4(AGENTS_SKILLS_DIR, entry.name), {
32931
+ mkdirSync3(skillsDir, { recursive: true });
32932
+ cpSync(join4(skillsSrc, entry.name), join4(skillsDir, entry.name), {
32906
32933
  recursive: true,
32907
32934
  force: true
32908
32935
  });
@@ -32912,13 +32939,21 @@ function ensureAgentSkillsInstalled() {
32912
32939
  for (const script of readdirSync3(join4(repo, "hooks", "scripts"))) {
32913
32940
  cpSync(join4(repo, "hooks", "scripts", script), join4(AGENT_HOOKS_DIR, script), { force: true });
32914
32941
  }
32915
- writeFileSync4(AGENT_SKILLS_MANIFEST, JSON.stringify({ version, skills: names }, null, 2) + "\n");
32916
- console.log(`Installed ${names.length} Gipity skills for Codex (~/.agents/skills).`);
32942
+ writeFileSync4(manifestPath, JSON.stringify({ version, skills: names }, null, 2) + "\n");
32943
+ console.log(`Installed ${names.length} Gipity skills for ${harnessLabel} (${skillsDir}).`);
32917
32944
  } catch {
32918
32945
  } finally {
32919
32946
  rmSync(tmp, { recursive: true, force: true });
32920
32947
  }
32921
32948
  }
32949
+ function ensureAgentSkillsInstalled() {
32950
+ if (agentSkillsState().current) return;
32951
+ installSkillsAndHooks(AGENTS_SKILLS_DIR, AGENT_SKILLS_MANIFEST, "Codex");
32952
+ }
32953
+ function ensureAgySkillsInstalled() {
32954
+ if (agySkillsState().current) return;
32955
+ installSkillsAndHooks(AGY_SKILLS_DIR, AGY_SKILLS_MANIFEST, "Antigravity");
32956
+ }
32922
32957
  function applyCodexHooks(existing) {
32923
32958
  const launcher = join4(AGENT_HOOKS_DIR, "launch.sh");
32924
32959
  const cmd = (script, ...args) => [`sh "${launcher}" "${join4(AGENT_HOOKS_DIR, script)}"`, ...args].join(" ");
@@ -32976,6 +33011,102 @@ function setupCodexIntegration() {
32976
33011
  ensureAgentSkillsInstalled();
32977
33012
  setupCodexHooks();
32978
33013
  }
33014
+ var AGY_HOOKS_SCRIPT = `#!/usr/bin/env node
33015
+ 'use strict';
33016
+ const { spawnSync } = require('child_process');
33017
+ const { join } = require('path');
33018
+
33019
+ const WRITE_TOOLS = new Set(['write_to_file', 'replace_file_content']);
33020
+
33021
+ function readStdin() {
33022
+ return new Promise((res) => {
33023
+ let data = '';
33024
+ process.stdin.setEncoding('utf-8');
33025
+ process.stdin.on('data', (c) => { data += c; });
33026
+ process.stdin.on('end', () => res(data));
33027
+ process.stdin.on('error', () => res(data));
33028
+ });
33029
+ }
33030
+
33031
+ async function main() {
33032
+ const event = process.argv[2];
33033
+ const raw = await readStdin();
33034
+ let payload = {};
33035
+ try { payload = JSON.parse(raw); } catch { /* keep {} */ }
33036
+
33037
+ try {
33038
+ if (event === 'post-tool-use') {
33039
+ const toolCall = payload.toolCall;
33040
+ const filePath = toolCall && toolCall.args && toolCall.args.TargetFile;
33041
+ if (toolCall && WRITE_TOOLS.has(toolCall.name) && typeof filePath === 'string' && filePath) {
33042
+ spawnSync(process.execPath, [join(__dirname, 'sync-push.cjs')], {
33043
+ input: JSON.stringify({ tool_input: { file_path: filePath } }),
33044
+ stdio: ['pipe', 'ignore', 'ignore'],
33045
+ windowsHide: true,
33046
+ });
33047
+ }
33048
+ spawnSync(process.execPath, [join(__dirname, 'capture.cjs'), 'agy', 'post-tool-use'], {
33049
+ input: raw,
33050
+ stdio: ['pipe', 'ignore', 'ignore'],
33051
+ windowsHide: true,
33052
+ });
33053
+ } else if (event === 'stop') {
33054
+ spawnSync(process.execPath, [join(__dirname, 'capture.cjs'), 'agy', 'stop'], {
33055
+ input: raw,
33056
+ stdio: ['pipe', 'ignore', 'ignore'],
33057
+ windowsHide: true,
33058
+ });
33059
+ }
33060
+ } catch { /* never let a side effect break the response below */ }
33061
+
33062
+ process.stdout.write('{}');
33063
+ }
33064
+
33065
+ main();
33066
+ `;
33067
+ function applyAgyHooks(existing) {
33068
+ const wrapper = join4(AGENT_HOOKS_DIR, "agy-hooks.cjs");
33069
+ const launcher = join4(AGENT_HOOKS_DIR, "launch.sh");
33070
+ const wrapCmd = (event) => `sh "${launcher}" "${wrapper}" ${event}`;
33071
+ const block = {
33072
+ PostToolUse: [
33073
+ { matcher: ".*", hooks: [{ type: "command", command: wrapCmd("post-tool-use"), timeout: 30 }] }
33074
+ ],
33075
+ Stop: [
33076
+ { hooks: [{ type: "command", command: wrapCmd("stop"), timeout: 60 }] }
33077
+ ]
33078
+ };
33079
+ let settings = {};
33080
+ if (existing !== null) {
33081
+ try {
33082
+ settings = JSON.parse(existing);
33083
+ } catch {
33084
+ return null;
33085
+ }
33086
+ }
33087
+ if (JSON.stringify(settings.gipity ?? null) === JSON.stringify(block)) return null;
33088
+ settings.gipity = block;
33089
+ return JSON.stringify(settings, null, 2) + "\n";
33090
+ }
33091
+ function setupAgyHooks() {
33092
+ if (process.platform === "win32") return;
33093
+ const cwd = resolve3(process.cwd());
33094
+ if (cwd === resolve3(homedir3())) return;
33095
+ mkdirSync3(AGENT_HOOKS_DIR, { recursive: true });
33096
+ writeFileSync4(join4(AGENT_HOOKS_DIR, "agy-hooks.cjs"), AGY_HOOKS_SCRIPT);
33097
+ const path5 = join4(cwd, ".agents", "hooks.json");
33098
+ const existing = existsSync4(path5) ? readFileSync6(path5, "utf-8") : null;
33099
+ const next = applyAgyHooks(existing);
33100
+ if (next === null) return;
33101
+ mkdirSync3(dirname3(path5), { recursive: true });
33102
+ writeFileSync4(path5, next);
33103
+ console.log(existing === null ? "Wrote Antigravity sync + session-capture hooks (.agents/hooks.json)." : "Updated Antigravity hooks (.agents/hooks.json).");
33104
+ }
33105
+ function setupAgyIntegration() {
33106
+ if (!binaryOnPath("agy")) return;
33107
+ ensureAgySkillsInstalled();
33108
+ setupAgyHooks();
33109
+ }
32979
33110
  function setupClaudeHooks() {
32980
33111
  ensureGipityPlugin();
32981
33112
  const cwd = resolve3(process.cwd());
@@ -33097,6 +33228,7 @@ var SUPPORTED_TOOLS = [
33097
33228
  { key: "claude", label: "Claude Code (CLAUDE.md + Gipity plugin)", setup: setupClaudeMd, integrate: setupClaudeHooks },
33098
33229
  { key: "codex", label: "OpenAI Codex (AGENTS.md + skills + sync hooks)", setup: setupAgentsMd, integrate: setupCodexIntegration },
33099
33230
  { key: "grok", label: "Grok Build (AGENTS.md + Gipity plugin)", setup: setupAgentsMd, integrate: ensureGrokPluginInstalled },
33231
+ { key: "agy", label: "Antigravity (AGENTS.md + skills + sync hooks)", setup: setupAgentsMd, integrate: setupAgyIntegration },
33100
33232
  { key: "aider", label: "Aider (AGENTS.md + .aider.conf.yml)", setup: setupAiderMd, optIn: true },
33101
33233
  { key: "gemini", label: "Gemini CLI (GEMINI.md)", setup: setupGeminiMd },
33102
33234
  { key: "copilot", label: "GitHub Copilot (.github/copilot-instructions.md)", setup: setupCopilotMd },
@@ -33226,22 +33358,22 @@ async function acquireLock(progress) {
33226
33358
  }
33227
33359
  }
33228
33360
  }
33229
- function readBaseline(projectGuid) {
33361
+ function readBaseline(projectGuid2) {
33230
33362
  const path5 = syncStatePath();
33231
- if (!existsSync5(path5)) return { projectGuid, files: {}, lastFullSync: null };
33363
+ if (!existsSync5(path5)) return { projectGuid: projectGuid2, files: {}, lastFullSync: null };
33232
33364
  try {
33233
33365
  const parsed = JSON.parse(readFileSync7(path5, "utf-8"));
33234
- if (parsed.projectGuid !== projectGuid) {
33235
- return { projectGuid, files: {}, lastFullSync: null };
33366
+ if (parsed.projectGuid !== projectGuid2) {
33367
+ return { projectGuid: projectGuid2, files: {}, lastFullSync: null };
33236
33368
  }
33237
33369
  return {
33238
- projectGuid,
33370
+ projectGuid: projectGuid2,
33239
33371
  files: parsed.files ?? {},
33240
33372
  lastFullSync: parsed.lastFullSync ?? null,
33241
33373
  ...parsed.deletesSkippedStreak ? { deletesSkippedStreak: parsed.deletesSkippedStreak } : {}
33242
33374
  };
33243
33375
  } catch {
33244
- return { projectGuid, files: {}, lastFullSync: null };
33376
+ return { projectGuid: projectGuid2, files: {}, lastFullSync: null };
33245
33377
  }
33246
33378
  }
33247
33379
  function writeBaseline(b7) {
@@ -33314,8 +33446,8 @@ function resolveInRoot(root, relPath) {
33314
33446
  }
33315
33447
  return full;
33316
33448
  }
33317
- async function fetchRemote(projectGuid) {
33318
- const res = await get(`/projects/${projectGuid}/files/tree`);
33449
+ async function fetchRemote(projectGuid2) {
33450
+ const res = await get(`/projects/${projectGuid2}/files/tree`);
33319
33451
  const out = /* @__PURE__ */ new Map();
33320
33452
  for (const f of res.data) {
33321
33453
  if (f.type !== "file") continue;
@@ -33376,14 +33508,14 @@ function extractTarToMap(stream, idleMs, onBytes, keep) {
33376
33508
  stream.pipe(extract3);
33377
33509
  });
33378
33510
  }
33379
- async function downloadAll(projectGuid, onBytes) {
33380
- const stream = await downloadStream(`/projects/${projectGuid}/files/tree?content=tar`);
33511
+ async function downloadAll(projectGuid2, onBytes) {
33512
+ const stream = await downloadStream(`/projects/${projectGuid2}/files/tree?content=tar`);
33381
33513
  return extractTarToMap(stream, DOWNLOAD_IDLE_MS, onBytes);
33382
33514
  }
33383
- async function fetchOneOutcome(projectGuid, path5, expectedSha) {
33515
+ async function fetchOneOutcome(projectGuid2, path5, expectedSha) {
33384
33516
  try {
33385
33517
  const stream = await downloadStream(
33386
- `/projects/${projectGuid}/files/tree?content=tar&path=${encodeURIComponent(path5)}`
33518
+ `/projects/${projectGuid2}/files/tree?content=tar&path=${encodeURIComponent(path5)}`
33387
33519
  );
33388
33520
  const want = normalizeTreePath(path5);
33389
33521
  const files = await extractTarToMap(stream, DOWNLOAD_IDLE_MS, void 0, (p) => p === want);
@@ -33399,8 +33531,8 @@ async function fetchOneOutcome(projectGuid, path5, expectedSha) {
33399
33531
  return { kind: "transient" };
33400
33532
  }
33401
33533
  }
33402
- async function fetchOne(projectGuid, path5, expectedSha) {
33403
- const out = await fetchOneOutcome(projectGuid, path5, expectedSha);
33534
+ async function fetchOne(projectGuid2, path5, expectedSha) {
33535
+ const out = await fetchOneOutcome(projectGuid2, path5, expectedSha);
33404
33536
  return out.kind === "ok" ? out.buf : null;
33405
33537
  }
33406
33538
  function classifyLocal(info2, base) {
@@ -33632,14 +33764,14 @@ async function sync(opts = {}) {
33632
33764
  releaseLock();
33633
33765
  }
33634
33766
  }
33635
- async function syncInner(projectGuid, root, ignore2, opts, interactive) {
33636
- const config = { projectGuid, ignore: ignore2 };
33767
+ async function syncInner(projectGuid2, root, ignore2, opts, interactive) {
33768
+ const config = { projectGuid: projectGuid2, ignore: ignore2 };
33637
33769
  const p = opts.progress;
33638
- const baseline = readBaseline(projectGuid);
33770
+ const baseline = readBaseline(projectGuid2);
33639
33771
  p?.phase("Scanning local files...");
33640
33772
  const local = walkLocal(root, ignore2, baseline.files);
33641
33773
  p?.phase("Checking Gipity for changes...");
33642
- const remote = await fetchRemote(projectGuid);
33774
+ const remote = await fetchRemote(projectGuid2);
33643
33775
  for (const path5 of [...remote.keys()]) {
33644
33776
  if (shouldIgnore(path5, ignore2)) remote.delete(path5);
33645
33777
  }
@@ -34439,7 +34571,12 @@ function parseDuration(raw, unit) {
34439
34571
  }
34440
34572
 
34441
34573
  // src/commands/token.ts
34442
- var tokenCommand = new Command("token").description("Manage API tokens").addHelpText("after", "\nLong-lived agent API tokens (gip_at_*) for headless agents and CI.");
34574
+ var tokenCommand = new Command("token").description("Manage API tokens").addHelpText("after", `
34575
+ Long-lived agent API tokens (gip_at_*) for headless agents and CI - they drive
34576
+ the gipity CLI as YOU.
34577
+
34578
+ To let a script or cron write to one app instead, mint a project API key:
34579
+ gipity key create "my script" --role editor (sent as X-Api-Key).`);
34443
34580
  var fmtDate = (d) => d ? new Date(d).toLocaleDateString() : "never";
34444
34581
  tokenCommand.command("create").description("Mint a long-lived agent API token (shown once)").option("--name <name>", 'Label for the token, e.g. "Hermes on my VPS"').option("--expires <days>", "Days until the token expires (default: never)").option("--json", "Output as JSON").action((opts) => run("Create", async () => {
34445
34582
  const body = {};
@@ -34484,6 +34621,73 @@ tokenCommand.command("revoke <short_guid>").alias("rm").description("Revoke an a
34484
34621
  console.log(success(`Revoked token ${bold(shortGuid)}.`));
34485
34622
  }));
34486
34623
 
34624
+ // src/commands/key.ts
34625
+ init_api();
34626
+ init_config();
34627
+ init_colors();
34628
+ var keyCommand = new Command("key").description("Manage project API keys for scripts and agents (X-Api-Key)").addHelpText("after", `
34629
+ Give a script, cron, or agent write access to your app without a login:
34630
+
34631
+ gipity key create "laptop importer" --role editor
34632
+ # the script sends: X-Api-Key: <the key printed once above>
34633
+
34634
+ Inside a function the caller shows up as ctx.auth.via === 'api_key' with
34635
+ ctx.auth.apiKeyName set to the key's name - stamp that on rows to tell
34636
+ script-written entries from hand-entered ones. Never build your own key table.
34637
+
34638
+ Account-level tokens that run the CLI headlessly are a different thing: see
34639
+ gipity token --help.`);
34640
+ var fmtDate2 = (d) => d ? new Date(d).toLocaleDateString() : "never";
34641
+ var projectOpt = ["--project <guid-or-slug>", "Target a specific project instead of cwd / Home"];
34642
+ async function projectGuid(opts) {
34643
+ const { config } = await resolveProjectContext({ projectOverride: opts.project });
34644
+ return config.projectGuid;
34645
+ }
34646
+ keyCommand.command("create <name>").description("Mint a project API key (shown once). Give it a name you will recognize later").option("--role <role>", "viewer | editor | owner (default: viewer)", "viewer").option("--expires-days <n>", "Days until the key expires (default: never)").option(...projectOpt).option("--json", "Output as JSON").action((name, opts) => run("Create", async () => {
34647
+ const body = { name, role: opts.role };
34648
+ if (opts.expiresDays !== void 0) {
34649
+ const days = parseInt(opts.expiresDays, 10);
34650
+ if (!Number.isFinite(days) || days <= 0) throw new Error("--expires-days must be a positive number of days");
34651
+ body.expires_in_days = days;
34652
+ }
34653
+ const res = await post(
34654
+ `/projects/${await projectGuid(opts)}/api-keys`,
34655
+ body
34656
+ );
34657
+ const k7 = res.data;
34658
+ if (opts.json) {
34659
+ console.log(JSON.stringify(k7));
34660
+ return;
34661
+ }
34662
+ const expNote = k7.expires_at ? ` (expires ${fmtDate2(k7.expires_at)})` : " (never expires)";
34663
+ console.log(success(`Created API key ${bold(k7.short_guid)} "${k7.name}" as ${k7.role}${muted(expNote)}.`));
34664
+ console.log("");
34665
+ console.log(k7.key);
34666
+ console.log("");
34667
+ console.log(muted("Send it from your script on every request:"));
34668
+ console.log(muted(` curl -H "X-Api-Key: ${k7.key}" ...`));
34669
+ console.log(muted(`Revoke it any time: gipity key revoke ${k7.short_guid}`));
34670
+ console.log("");
34671
+ console.log(warning("Copy it now - it will not be shown again."));
34672
+ }));
34673
+ keyCommand.command("list").alias("ls").description("List this project's API keys (values are never shown again)").option(...projectOpt).option("--json", "Output as JSON").action((opts) => run("List", async () => {
34674
+ const res = await get(`/projects/${await projectGuid(opts)}/api-keys`);
34675
+ printList(
34676
+ res.data,
34677
+ opts,
34678
+ 'No API keys. Mint one with: gipity key create "my script" --role editor',
34679
+ (k7) => `${bold(k7.short_guid)} ${k7.name} ${muted(`${k7.role} ${k7.prefix}\u2026 last used ${fmtDate2(k7.last_used_at)} expires ${fmtDate2(k7.expires_at)}`)}`
34680
+ );
34681
+ }));
34682
+ keyCommand.command("revoke <short_guid>").alias("rm").description("Revoke a project API key (instant, irreversible)").option(...projectOpt).option("--json", "Output as JSON").action((shortGuid, opts) => run("Revoke", async () => {
34683
+ await del(`/projects/${await projectGuid(opts)}/api-keys/${encodeURIComponent(shortGuid)}`);
34684
+ if (opts.json) {
34685
+ console.log(JSON.stringify({ short_guid: shortGuid, revoked: true }));
34686
+ return;
34687
+ }
34688
+ console.log(success(`Revoked API key ${bold(shortGuid)}.`));
34689
+ }));
34690
+
34487
34691
  // src/commands/init.ts
34488
34692
  init_api();
34489
34693
  init_config();
@@ -34529,6 +34733,80 @@ function escapeHtml(s) {
34529
34733
  function jsEscape(s) {
34530
34734
  return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
34531
34735
  }
34736
+ var ACCENT_PALETTE = [
34737
+ "#f59e0b",
34738
+ "#f97316",
34739
+ "#ef4444",
34740
+ "#ec4899",
34741
+ "#a855f7",
34742
+ "#6366f1",
34743
+ "#3b82f6",
34744
+ "#0ea5e9",
34745
+ "#14b8a6",
34746
+ "#22c55e",
34747
+ "#84cc16",
34748
+ "#eab308"
34749
+ ];
34750
+ function hashString(s) {
34751
+ let h = 2166136261;
34752
+ for (let i = 0; i < s.length; i++) {
34753
+ h ^= s.charCodeAt(i);
34754
+ h = Math.imul(h, 16777619);
34755
+ }
34756
+ return h >>> 0;
34757
+ }
34758
+ function darkTint(hex) {
34759
+ const n = parseInt(hex.slice(1), 16);
34760
+ const base = 1052692;
34761
+ const ch = (shift) => {
34762
+ const a = n >> shift & 255;
34763
+ const b7 = base >> shift & 255;
34764
+ return Math.round(b7 + (a - b7) * 0.14).toString(16).padStart(2, "0");
34765
+ };
34766
+ return `#${ch(16)}${ch(8)}${ch(0)}`;
34767
+ }
34768
+ function buildHeadBlock(v7) {
34769
+ const t = escapeHtml(v7.projectName);
34770
+ const d = v7.description ? escapeHtml(v7.description) : "";
34771
+ const url = v7.accountSlug && v7.projectSlug ? `https://app.gipity.ai/${v7.accountSlug}/${v7.projectSlug}/` : void 0;
34772
+ const themeColor = darkTint(ACCENT_PALETTE[hashString(v7.projectGuid || v7.projectName) % ACCENT_PALETTE.length]);
34773
+ const jsonLd = JSON.stringify({
34774
+ "@context": "https://schema.org",
34775
+ "@type": "WebApplication",
34776
+ name: v7.projectName,
34777
+ ...v7.description ? { description: v7.description } : {},
34778
+ ...url ? { url } : {}
34779
+ }, null, 2).replace(/<\//g, "<\\/");
34780
+ const lines = [
34781
+ `<title>${t}</title>`,
34782
+ ...d ? [`<meta name="description" content="${d}">`] : [],
34783
+ ...url ? [`<link rel="canonical" href="${url}">`] : [],
34784
+ `<meta property="og:title" content="${t}">`,
34785
+ `<meta property="og:type" content="website">`,
34786
+ ...d ? [`<meta property="og:description" content="${d}">`] : [],
34787
+ ...url ? [
34788
+ `<meta property="og:url" content="${url}">`,
34789
+ `<meta property="og:image" content="${url}images/og-image.png">`,
34790
+ `<meta property="og:image:width" content="1200">`,
34791
+ `<meta property="og:image:height" content="630">`
34792
+ ] : [],
34793
+ `<meta name="twitter:card" content="summary_large_image">`,
34794
+ `<meta name="twitter:title" content="${t}">`,
34795
+ ...d ? [`<meta name="twitter:description" content="${d}">`] : [],
34796
+ ...url ? [`<meta name="twitter:image" content="${url}images/og-image.png">`] : [],
34797
+ `<meta name="theme-color" content="${themeColor}">`,
34798
+ `<link rel="icon" type="image/png" sizes="192x192" href="./images/favicon-192.png">`,
34799
+ `<link rel="icon" type="image/png" sizes="512x512" href="./images/favicon-512.png">`,
34800
+ `<link rel="icon" type="image/x-icon" href="./images/favicon.ico">`,
34801
+ `<link rel="apple-touch-icon" href="./images/apple-touch-icon.png">`,
34802
+ `<link rel="manifest" href="./manifest.webmanifest">`,
34803
+ `<script type="application/ld+json">
34804
+ ${jsonLd}
34805
+ </script>`
34806
+ ];
34807
+ return lines.map((l7) => `
34808
+ ${l7}`).join("");
34809
+ }
34532
34810
  function buildTemplateVars(v7) {
34533
34811
  const safeTitle = escapeHtml(v7.projectName);
34534
34812
  const safeDesc = v7.description ? escapeHtml(v7.description) : "";
@@ -34544,6 +34822,9 @@ function buildTemplateVars(v7) {
34544
34822
  "{{JS_TITLE}}": jsEscape(v7.projectName),
34545
34823
  "{{PROJECT_GUID}}": v7.projectGuid,
34546
34824
  "{{DATABASE}}": slug,
34825
+ "{{HEAD_BLOCK}}": buildHeadBlock(v7),
34826
+ // Legacy per-tag placeholders — current templates carry only {{HEAD_BLOCK}},
34827
+ // but older local template copies may still reference these.
34547
34828
  "{{DESCRIPTION_META}}": v7.description ? `
34548
34829
  <meta name="description" content="${safeDesc}">` : "",
34549
34830
  "{{OG_DESCRIPTION}}": v7.description ? `
@@ -34638,7 +34919,9 @@ async function finalizeLocalProject(opts) {
34638
34919
  try {
34639
34920
  const sub = await substituteDir(opts.dir, {
34640
34921
  projectGuid: opts.projectGuid,
34641
- projectName: opts.projectName
34922
+ projectName: opts.projectName,
34923
+ accountSlug: opts.accountSlug,
34924
+ projectSlug: opts.projectSlug
34642
34925
  });
34643
34926
  if (sub.changed.length) {
34644
34927
  console.log(muted(`Resolved template vars in ${sub.changed.length} file${sub.changed.length > 1 ? "s" : ""}.`));
@@ -35024,7 +35307,7 @@ function resolveTools(forFlag) {
35024
35307
  (t) => requested.includes(t.key) || !t.optIn && requested.includes("all")
35025
35308
  );
35026
35309
  }
35027
- var initCommand = new Command("init").description("Link this directory to a project").addHelpText("after", "\nWrites CLAUDE.md/AGENTS.md primer files so your AI coding tool understands Gipity, and installs the Gipity skills + file-sync hooks into the agent CLIs found on this machine (Claude Code, Codex, Grok).").argument("[name]", "Project name/slug (defaults to current directory name)").option("--agent <guid>", "Agent GUID to use").option("--no-capture", "Don't record Claude Code sessions in this directory to your Gipity project (sets captureHooks: false in .gipity.json)").option(
35310
+ var initCommand = new Command("init").description("Link this directory to a project").addHelpText("after", "\nWrites CLAUDE.md/AGENTS.md primer files so your AI coding tool understands Gipity, and installs the Gipity skills + file-sync hooks into the agent CLIs found on this machine (Claude Code, Codex, Grok, Antigravity).").argument("[name]", "Project name/slug (defaults to current directory name)").option("--agent <guid>", "Agent GUID to use").option("--no-capture", "Don't record Claude Code sessions in this directory to your Gipity project (sets captureHooks: false in .gipity.json)").option(
35028
35311
  "--for <tools>",
35029
35312
  `Which AI tool primer files to write (comma-separated). Default: all except aider (opt-in - it also writes .aider.conf.yml). Choices: ${TOOL_KEYS.join(", ")}, all`
35030
35313
  ).addHelpText("after", `
@@ -35152,7 +35435,7 @@ Working with an existing Gipity project:
35152
35435
  }
35153
35436
  console.log(success(`Wrote primer files: ${primerSummary}.`));
35154
35437
  if (wantsClaude) {
35155
- console.log(success("Ready! Run your coding agent here (claude, codex, grok), or `gipity build` to launch one with a picker."));
35438
+ console.log(success("Ready! Run your coding agent here (claude, codex, grok, agy), or `gipity build` to launch one with a picker."));
35156
35439
  if (opts.capture === false) {
35157
35440
  console.log(muted("Session recording is off for this project (captureHooks: false in .gipity.json)."));
35158
35441
  } else {
@@ -35448,6 +35731,30 @@ init_colors();
35448
35731
  init_auth();
35449
35732
  init_config();
35450
35733
 
35734
+ // src/db-checkpoint.ts
35735
+ init_api();
35736
+ async function resolveDatabase(projectGuid2, explicit) {
35737
+ if (explicit) return explicit;
35738
+ const res = await get(`/projects/${projectGuid2}/databases`);
35739
+ return res.data.length > 0 ? res.data[0].friendlyName : null;
35740
+ }
35741
+ async function checkpoint(projectGuid2, database, action, keep) {
35742
+ const res = await post(
35743
+ `/projects/${projectGuid2}/db/checkpoint`,
35744
+ { database, action, ...keep ? { keep } : {} }
35745
+ );
35746
+ return res.data;
35747
+ }
35748
+ function createCheckpoint(projectGuid2, database) {
35749
+ return checkpoint(projectGuid2, database, "create");
35750
+ }
35751
+ function restoreCheckpoint(projectGuid2, database, opts = {}) {
35752
+ return checkpoint(projectGuid2, database, "restore", opts.keep);
35753
+ }
35754
+ function dropCheckpoint(projectGuid2, database) {
35755
+ return checkpoint(projectGuid2, database, "drop");
35756
+ }
35757
+
35451
35758
  // src/page-fixtures.ts
35452
35759
  init_api();
35453
35760
  init_config();
@@ -35490,13 +35797,13 @@ Nothing named "${basename4(localPath)}" under ${root} either. Generate a frame w
35490
35797
  `${flag} ${localPath}: no such file \u2014 looked for ${abs} (relative paths resolve against the current directory, ${process.cwd()}).${found}`
35491
35798
  );
35492
35799
  }
35493
- async function uploadPublicFixture(projectGuid, localPath, flag = "--fixture") {
35800
+ async function uploadPublicFixture(projectGuid2, localPath, flag = "--fixture") {
35494
35801
  assertLocalAsset(flag, localPath);
35495
35802
  const name = basename4(localPath);
35496
35803
  const size = statSync6(localPath).size;
35497
35804
  const contentType = guessMime(localPath);
35498
35805
  const init = await post(
35499
- `/api/${projectGuid}/uploads/init`,
35806
+ `/api/${projectGuid2}/uploads/init`,
35500
35807
  { filename: name, content_type: contentType, size, public: true }
35501
35808
  );
35502
35809
  if (init.data.method !== "PUT" || !init.data.url) {
@@ -35511,13 +35818,13 @@ async function uploadPublicFixture(projectGuid, localPath, flag = "--fixture") {
35511
35818
  throw new Error(`upload of fixture "${name}" failed: ${res.status} ${res.statusText}`);
35512
35819
  }
35513
35820
  const done = await post(
35514
- `/api/${projectGuid}/uploads/complete`,
35821
+ `/api/${projectGuid2}/uploads/complete`,
35515
35822
  { upload_guid: init.data.upload_guid }
35516
35823
  );
35517
35824
  return { guid: done.data.guid, url: done.data.url, name, localPath };
35518
35825
  }
35519
- async function deleteFixture(projectGuid, guid) {
35520
- await del(`/api/${projectGuid}/uploads/${guid}`);
35826
+ async function deleteFixture(projectGuid2, guid) {
35827
+ await del(`/api/${projectGuid2}/uploads/${guid}`);
35521
35828
  }
35522
35829
  var CAMERA_EXTS = [".png", ".jpg", ".jpeg", ".webp", ".mp4", ".webm", ".y4m", ".mjpeg"];
35523
35830
  function assertCameraFile(localPath) {
@@ -35530,9 +35837,9 @@ A still image is played as a looping single-frame feed, which is what a gesture/
35530
35837
  No frame to hand? Generate one: gipity generate image "a hand making a closed fist, palm to camera, plain background"`
35531
35838
  );
35532
35839
  }
35533
- async function uploadCameraFeed(projectGuid, localPath) {
35840
+ async function uploadCameraFeed(projectGuid2, localPath) {
35534
35841
  assertCameraFile(localPath);
35535
- return uploadPublicFixture(projectGuid, localPath, "--camera");
35842
+ return uploadPublicFixture(projectGuid2, localPath, "--camera");
35536
35843
  }
35537
35844
 
35538
35845
  // src/commands/page-eval.ts
@@ -35655,7 +35962,15 @@ function budgetOverrunHint(reason, usedBudgetMs) {
35655
35962
  }
35656
35963
  return `That budget was ${Math.round(usedBudgetMs / 1e3)}s. Raise it with --timeout <ms> (up to ${EVAL_SCRIPT_BUDGET_MAX_MS}), e.g. --timeout ${EVAL_SCRIPT_BUDGET_MAX_MS} \u2014 or gate on a ready signal instead: --wait-for '<selector>' --wait-timeout ${MAX_WAIT_MS}.`;
35657
35964
  }
35965
+ function navigationAbortHint(reason) {
35966
+ if (!/navigated or reloaded/i.test(reason)) return null;
35967
+ return `If you were verifying that state SURVIVES a reload, that is what --reload is for (one command, one page): gipity page eval "<url>" '<seed/assert expr>' --reload '<assert-restored expr>'. It reloads the page in place (localStorage/sessionStorage/cookies preserved) and reports the second result separately. Splitting into two 'page eval' calls does NOT do this: each call is its own browser session, so the second one starts from empty storage and never exercises the restore path.`;
35968
+ }
35969
+ function stepsDeterministically(script) {
35970
+ return /\badvance\s*\(/.test(script);
35971
+ }
35658
35972
  function slowRenderMessage(fps, o) {
35973
+ if (!o.camera && stepsDeterministically(o.script ?? "")) return null;
35659
35974
  if (o.camera) {
35660
35975
  const frames = Math.max(1, Math.round(fps * (o.waitMs / 1e3)));
35661
35976
  return `${warning("\u26A0 Slow render:")} page painted at ${fps} fps, so the app's vision pipeline ran on roughly ${bold(`${frames} frame${frames === 1 ? "" : "s"}`)} during the ${Math.round(o.waitMs / 1e3)}s before this eval (it infers once per painted frame). ${bold("That is enough:")} --camera loops your still image, so every one of those frames is the SAME pixels and the model returns the SAME answer on each \u2014 re-running with a bigger --wait/--timeout cannot change the result. ${bold("Do not escalate the wait.")} If a detection landed, it is real. If nothing was detected, the suspect is the ${bold("frame")} (a model needs the whole subject in shot \u2014 a tight crop, an odd angle or a busy background reads as nothing) or the ${bold("app")} (frames never reach the model) \u2014 never the frame rate. Settle it in ONE run: ${CAMERA_FRAME_CHECK}`;
@@ -35713,14 +36028,14 @@ var pageEvalCommand = new Command("eval").description("Evaluate JS in a real bro
35713
36028
  []
35714
36029
  ).option(
35715
36030
  "--reload <expr>",
35716
- "After the first eval, reload the page IN PLACE (localStorage/sessionStorage/cookies preserved) and evaluate this second expression against the post-reload DOM. One command verifies persisted state survives a reload: seed/assert state with <expr>, then assert the restored UI here."
36031
+ 'After the first eval, reload the page IN PLACE (localStorage/sessionStorage/cookies preserved) and evaluate this second expression against the post-reload DOM. One command verifies persisted state survives a reload ("remember it when I come back"): seed/assert state with <expr>, then assert the restored UI here. This is the ONLY way to check that - reloading inside the body aborts the eval (the result is lost with the old page), and two separate `page eval` calls are two fresh browser profiles, so the second starts from empty storage.'
35717
36032
  ).option("--reload-file <path>", "Read the post-reload expression from a file instead of inline --reload (mutually exclusive)").option(
35718
36033
  "--camera <path>",
35719
36034
  `Play a local image or video (.png/.jpg/.webp/.mp4/.webm/.y4m/.mjpeg) as the browser's WEBCAM feed, so a camera app's real pipeline (getUserMedia \u2192 MediaPipe/YOLOX \u2192 your app logic) runs headlessly on a frame you choose. Implies --fake-media and waits ${CAMERA_DEFAULT_WAIT_MS / 1e3}s for the vision model to load. No frame handy? gipity generate image "a hand making a closed fist, palm to camera".`
35720
36035
  ).option("--fake-media", "Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so a voice/camera app runs headlessly instead of hitting its no-camera path. The feed is a built-in test pattern (and a tone) \u2014 nothing a vision model can recognize; to drive a vision app use --camera <path> instead.").option("--wait <ms>", "Sleep this many ms after DOMContentLoaded before evaluating (lets late async work settle; max 30000). With --wait-for, it elapses AFTER the selector appears - gate, then settle.", "500").option("--wait-for <selector>", `Wait until this CSS selector appears before evaluating, then evaluate (deterministic - beats guessing a --wait). Gate on the state you are ASSERTING, not just a ready flag: '#verdict:not(:empty)' returns the instant the round lands, where a fixed wait snapshots mid-sequence. Same flag on page inspect/screenshot. Max ${WAIT_FOR_MAX_MS}ms (--wait-timeout).`).option("--wait-timeout <ms>", `Max ms to wait for --wait-for before giving up (max ${WAIT_FOR_MAX_MS})`, "5000").option(
35721
36036
  "--timeout <ms>",
35722
36037
  `How long the script itself may run IN the page. Bare number = MILLISECONDS (so 90s = 90000, NOT 90); or pass an explicit unit that means the same on both this and \`sandbox run --timeout\` \u2014 --timeout 90s. Its own await/setTimeout pauses count (default ${EVAL_SCRIPT_BUDGET_MS}, ${EVAL_SCRIPT_BUDGET_CAMERA_MS} with --camera/--fake-media; floor 1000, max ${EVAL_SCRIPT_BUDGET_MAX_MS}). Raise it to trace a sequence that unfolds over time (a game round, an animation). Distinct from --wait, which only sleeps BEFORE the script.`
35723
- ).option("--auth", "Evaluate signed in as you (your Gipity account), so a page behind a Sign-in-with-Gipity login is reachable. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai. Without this flag the page loads as a genuinely anonymous, signed-out visitor \u2014 nothing carries over from earlier --auth runs.").option("--json", "Output as JSON").action((url, exprArg, opts) => run("Page eval", async () => {
36038
+ ).option("--auth", "Evaluate signed in as you (your Gipity account), so a page behind a Sign-in-with-Gipity login is reachable. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai. Without this flag the page loads as a genuinely anonymous, signed-out visitor \u2014 nothing carries over from earlier --auth runs.").option("--restore-db", "Snapshot the app database before the script runs and roll it back after, so a write-path check (click Approve, submit the form, edit a row) leaves the real data untouched. Use this whenever the eval WRITES - it is the undo for driving the deployed app against real project data. Same snapshot/undo standalone: gipity db checkpoint / gipity db restore.").option("--json", "Output as JSON").action((url, exprArg, opts) => run("Page eval", async () => {
35724
36039
  const decoy = JS_DECOY_FLAGS.find((f) => opts[f.slice(2)] !== void 0);
35725
36040
  if (decoy) {
35726
36041
  pageEvalCommand.error(
@@ -35797,23 +36112,36 @@ var pageEvalCommand = new Command("eval").description("Evaluate JS in a real bro
35797
36112
  const fixturePaths = opts.fixture ?? [];
35798
36113
  const hosted = [];
35799
36114
  let camera;
35800
- let projectGuid;
36115
+ let projectGuid2;
35801
36116
  let sentExpr = expr;
35802
36117
  let sentSteps = steps;
35803
36118
  if (opts.camera) assertCameraFile(opts.camera);
36119
+ let restoreDb;
35804
36120
  try {
36121
+ if (opts.restoreDb) {
36122
+ const { config } = await resolveProjectContext({});
36123
+ projectGuid2 = config.projectGuid;
36124
+ const database = await resolveDatabase(config.projectGuid);
36125
+ if (!database) {
36126
+ console.error(muted("--restore-db: this project has no database - nothing to roll back."));
36127
+ } else {
36128
+ const cp2 = await createCheckpoint(config.projectGuid, database);
36129
+ console.error(muted(`Checkpointed ${cp2.tables.length} table(s), ${cp2.rows} row(s) in '${database}' - rolled back when this eval finishes.`));
36130
+ restoreDb = { projectGuid: config.projectGuid, database };
36131
+ }
36132
+ }
35805
36133
  if (fixturePaths.length || opts.camera) {
35806
36134
  const { config } = await resolveProjectContext({});
35807
- projectGuid = config.projectGuid;
36135
+ projectGuid2 = config.projectGuid;
35808
36136
  }
35809
36137
  if (opts.camera) {
35810
36138
  console.log(muted(`Hosting camera feed ${opts.camera}...`));
35811
- camera = await uploadCameraFeed(projectGuid, opts.camera);
36139
+ camera = await uploadCameraFeed(projectGuid2, opts.camera);
35812
36140
  }
35813
36141
  if (fixturePaths.length) {
35814
36142
  for (const p of fixturePaths) {
35815
36143
  console.log(muted(`Hosting fixture ${p}...`));
35816
- hosted.push(await uploadPublicFixture(projectGuid, p));
36144
+ hosted.push(await uploadPublicFixture(projectGuid2, p));
35817
36145
  }
35818
36146
  const map = {};
35819
36147
  for (const h of hosted) map[h.name] = h.url;
@@ -35851,7 +36179,7 @@ var pageEvalCommand = new Command("eval").description("Evaluate JS in a real bro
35851
36179
  }));
35852
36180
  } catch (err) {
35853
36181
  const msg = err?.message ?? "";
35854
- const hint = budgetOverrunHint(msg, scriptBudgetMs);
36182
+ const hint = budgetOverrunHint(msg, scriptBudgetMs) ?? (reloadExpr === void 0 ? navigationAbortHint(msg) : null);
35855
36183
  if (!hint) throw err;
35856
36184
  throw new Error(`${msg}
35857
36185
  ${hint}`);
@@ -35883,7 +36211,12 @@ ${hint}`);
35883
36211
  console.log(`${warning("\u26A0 Navigation incomplete:")} ${d.note || "page did not reach full load"}`);
35884
36212
  }
35885
36213
  if (d.slowRender) {
35886
- console.log(slowRenderMessage(d.slowRender.fps, { camera: !!camera, waitMs }));
36214
+ const slow = slowRenderMessage(d.slowRender.fps, {
36215
+ camera: !!camera,
36216
+ waitMs,
36217
+ script: [expr, ...steps, reloadExpr ?? ""].join("\n")
36218
+ });
36219
+ if (slow) console.log(slow);
35887
36220
  }
35888
36221
  if (d.auth?.requested) {
35889
36222
  const who = getAuth()?.email;
@@ -35916,9 +36249,17 @@ ${bold("After reload")} ${muted("(page reloaded in place \u2014 storage preserve
35916
36249
  if (d.reloadTruncated) console.log(muted("(reload result truncated to fit context - narrow the expression for the full value)"));
35917
36250
  }
35918
36251
  } finally {
36252
+ if (restoreDb) {
36253
+ try {
36254
+ const r = await restoreCheckpoint(restoreDb.projectGuid, restoreDb.database);
36255
+ console.error(muted(`Rolled ${r.tables.length} table(s) back to the checkpoint (${r.rows} row(s)) - nothing this eval wrote survives.`));
36256
+ } catch (err) {
36257
+ console.error(warning(`\u26A0 Could not roll back the database - the checkpoint is still there, retry with: gipity db restore (${err.message})`));
36258
+ }
36259
+ }
35919
36260
  for (const h of [...hosted, ...camera ? [camera] : []]) {
35920
36261
  try {
35921
- await deleteFixture(projectGuid, h.guid);
36262
+ await deleteFixture(projectGuid2, h.guid);
35922
36263
  } catch (err) {
35923
36264
  console.error(warning(`\u26A0 Could not auto-delete fixture "${h.name}" (${h.guid}) \u2014 still hosted at ${h.url}: ${err.message}`));
35924
36265
  }
@@ -36012,7 +36353,7 @@ init_auth();
36012
36353
  init_colors();
36013
36354
  init_utils();
36014
36355
  import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync8 } from "fs";
36015
- import { dirname as dirname7, join as join12, resolve as resolvePath } from "path";
36356
+ import { dirname as dirname7, extname as extname4, join as join12, resolve as resolvePath } from "path";
36016
36357
  var DEVICE_PRESETS = {
36017
36358
  default: { width: 1280, height: 720 },
36018
36359
  desktop: { width: 1920, height: 1080 },
@@ -36123,6 +36464,14 @@ function resolveDevice(name) {
36123
36464
  const available = [...Object.keys(DEVICE_PRESETS), ...TOUCH_DEVICES].join(", ");
36124
36465
  throw new Error(`Unknown --device preset: "${name}" (known: ${available})`);
36125
36466
  }
36467
+ function viewportSlug(name) {
36468
+ return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "shot";
36469
+ }
36470
+ function stemPath(output, suffix) {
36471
+ const ext = extname4(output) || ".png";
36472
+ const base = ext && output.endsWith(ext) ? output.slice(0, -ext.length) : output;
36473
+ return `${base}-${suffix}${ext}`;
36474
+ }
36126
36475
  function splitCsv(values) {
36127
36476
  if (!values || values.length === 0) return [];
36128
36477
  return values.flatMap((v7) => v7.split(",").map((s) => s.trim()).filter(Boolean));
@@ -36139,7 +36488,7 @@ var WAIT_FOR_MAX_MS2 = 15e3;
36139
36488
  var WAIT_FOR_DEFAULT_MS = 15e3;
36140
36489
  var MAX_POST_LOAD_DELAY_MS = 3e4;
36141
36490
  var CAMERA_DEFAULT_DELAY_MS = 15e3;
36142
- var pageScreenshotCommand = new Command("screenshot").description("Screenshot a web page").argument("<url>", "URL to screenshot").option("--device <names>", `Device preset(s): ${Object.keys(DEVICE_PRESETS).join(", ")} (comma-separated or repeat flag). mobile/tablet emulate a real touch device \u2014 touch events, mobile user-agent, DPR \u2014 so touch-gated mobile UI actually renders.`, appendOption, []).option("--viewport <dims>", "Raw viewport(s): WxH or WxH@dpr (comma-separated or repeat flag), e.g. --viewport 390x844 for a phone-sized window", appendOption, []).option("--wait <ms>", `Sleep this many ms after DOMContentLoaded before capturing (default 1000, max ${MAX_POST_LOAD_DELAY_MS}). With --camera it defaults to ${CAMERA_DEFAULT_DELAY_MS} so the vision model is up. Same flag as page eval/inspect.`).option("--wait-for <selector>", `Wait until this CSS selector appears before capturing, then capture (deterministic - beats guessing a --wait). Same flag as page eval/inspect. Gate on what you are photographing ('[data-vision="ready"]', '#verdict:not(:empty)'). Max ${WAIT_FOR_MAX_MS2}ms (--wait-timeout).`).option("--wait-timeout <ms>", `Max ms to wait for --wait-for before giving up (default ${WAIT_FOR_DEFAULT_MS}, max ${WAIT_FOR_MAX_MS2}). Timing out is reported - the capture still happens, so you see the state it got stuck in.`).option("--action <js>", `Run JS in the page before capturing - e.g. click a button to enter a state ("document.getElementById('play').click()"). Runs as an async function body, so const/await and app-relative import('./...') work. Runs after the post-load delay, then settles again before the shot. If it throws, the capture still happens and the failure is reported.`).option("--file <path>", "Read the pre-capture script from a file instead of inline --action (mutually exclusive), or --file - to read it from stdin (pipe a heredoc: --file - <<'EOF' ... EOF) with no tmp file. For a multi-step driver - click through a flow, wait for it to render - then capture. Same async-function-body semantics as --action; same --file flag as page eval.").option("--full", "Capture the full scrollable page (default: viewport only). Scrolls the page through first so scroll-reveal/fade-in-on-scroll (IntersectionObserver) sections render into the shot instead of capturing blank.").option("-o, --output <file>", "Output path (single viewport only; default .gipity/screenshots/ss-<host>-<timestamp>.png)").option("--no-reload-between", "Skip reload between viewports (faster, lower fidelity - only safe for static pages)").option("--fake-media", "Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps render headlessly. The video feed is a built-in test pattern \u2014 to capture what the app does with a REAL frame (a hand, a face, an object), use --camera <path> instead.").option("--camera <path>", "Play a local image or video (.png/.jpg/.webp/.mp4/.webm/.y4m/.mjpeg) as the browser's WEBCAM feed, then capture \u2014 so the shot shows the app reacting to a frame you chose (detected gesture, boxes, labels). Implies --fake-media.").option("--auth", "Capture the page signed in as you (your Gipity account), so UI behind a Sign-in-with-Gipity login is shown. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai. Without this flag the page loads as a genuinely anonymous, signed-out visitor \u2014 nothing carries over from earlier --auth runs.").option("--ephemeral", "Skip the project screenshot history: do not persist this capture to Gipity (screenshots/ in the project). Local file is still written.").option("--json", "Output JSON metadata instead of a friendly summary").addOption(new Option("--post-load-delay <ms>", "Alias for --wait").hideHelp()).addOption(new Option("--full-page", "Alias for --full").hideHelp()).addOption(new Option("--width <px>", "Alias: --viewport WxH").hideHelp()).addOption(new Option("--height <px>", "Alias: --viewport WxH").hideHelp()).action((url, opts) => run("Page screenshot", async () => {
36491
+ var pageScreenshotCommand = new Command("screenshot").description("Screenshot a web page").argument("<url>", "URL to screenshot").option("--device <names>", `Device preset(s): ${Object.keys(DEVICE_PRESETS).join(", ")} (comma-separated or repeat flag). mobile/tablet emulate a real touch device \u2014 touch events, mobile user-agent, DPR \u2014 so touch-gated mobile UI actually renders.`, appendOption, []).option("--viewport <dims>", "Raw viewport(s): WxH or WxH@dpr (comma-separated or repeat flag), e.g. --viewport 390x844 for a phone-sized window", appendOption, []).option("--wait <ms>", `Sleep this many ms after DOMContentLoaded before capturing (default 1000, max ${MAX_POST_LOAD_DELAY_MS}). With --camera it defaults to ${CAMERA_DEFAULT_DELAY_MS} so the vision model is up. Same flag as page eval/inspect.`).option("--wait-for <selector>", `Wait until this CSS selector appears before capturing, then capture (deterministic - beats guessing a --wait). Same flag as page eval/inspect. Gate on what you are photographing ('[data-vision="ready"]', '#verdict:not(:empty)'). Max ${WAIT_FOR_MAX_MS2}ms (--wait-timeout).`).option("--wait-timeout <ms>", `Max ms to wait for --wait-for before giving up (default ${WAIT_FOR_DEFAULT_MS}, max ${WAIT_FOR_MAX_MS2}). Timing out is reported - the capture still happens, so you see the state it got stuck in.`).option("--action <js>", `Run JS in the page before capturing - e.g. click a button to enter a state ("document.getElementById('play').click()"). Runs as an async function body, so const/await and app-relative import('./...') work. Runs after the post-load delay, then settles again before the shot. If it throws, the capture still happens and the failure is reported.`).option("--file <path>", "Read the pre-capture script from a file instead of inline --action (mutually exclusive), or --file - to read it from stdin (pipe a heredoc: --file - <<'EOF' ... EOF) with no tmp file. For a multi-step driver - click through a flow, wait for it to render - then capture. Same async-function-body semantics as --action; same --file flag as page eval.").option("--full", "Capture the full scrollable page (default: viewport only). Scrolls the page through first so scroll-reveal/fade-in-on-scroll (IntersectionObserver) sections render into the shot instead of capturing blank.").option("-o, --output <file>", "Output path (default .gipity/screenshots/ss-<host>-<timestamp>.png). With several viewports it is a stem: -o tmp/shot.png --device desktop,mobile writes tmp/shot-desktop.png and tmp/shot-mobile.png.").option("--no-reload-between", "Skip reload between viewports (faster, lower fidelity - only safe for static pages)").option("--fake-media", "Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps render headlessly. The video feed is a built-in test pattern \u2014 to capture what the app does with a REAL frame (a hand, a face, an object), use --camera <path> instead.").option("--camera <path>", "Play a local image or video (.png/.jpg/.webp/.mp4/.webm/.y4m/.mjpeg) as the browser's WEBCAM feed, then capture \u2014 so the shot shows the app reacting to a frame you chose (detected gesture, boxes, labels). Implies --fake-media.").option("--auth", "Capture the page signed in as you (your Gipity account), so UI behind a Sign-in-with-Gipity login is shown. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai. Without this flag the page loads as a genuinely anonymous, signed-out visitor \u2014 nothing carries over from earlier --auth runs.").option("--ephemeral", "Skip the project screenshot history: do not persist this capture to Gipity (screenshots/ in the project). Local file is still written.").option("--json", "Output JSON metadata instead of a friendly summary").addOption(new Option("--post-load-delay <ms>", "Alias for --wait").hideHelp()).addOption(new Option("--full-page", "Alias for --full").hideHelp()).addOption(new Option("--width <px>", "Alias: --viewport WxH").hideHelp()).addOption(new Option("--height <px>", "Alias: --viewport WxH").hideHelp()).action((url, opts) => run("Page screenshot", async () => {
36143
36492
  const aliasFlag = ACTION_ALIAS_FLAGS.find((f) => opts[f.slice(2)] !== void 0);
36144
36493
  if (aliasFlag) {
36145
36494
  console.error(muted(
@@ -36209,22 +36558,23 @@ var pageScreenshotCommand = new Command("screenshot").description("Screenshot a
36209
36558
  ...deviceNames.map(resolveDevice),
36210
36559
  ...viewportStrs.map(parseViewportString)
36211
36560
  ];
36212
- if (opts.output && customViewports.length > 1) {
36213
- throw new Error("--output can only be used with a single viewport");
36214
- }
36561
+ const viewportLabels = [
36562
+ ...deviceNames.map(viewportSlug),
36563
+ ...viewportStrs.map(viewportSlug)
36564
+ ];
36215
36565
  const userSpecifiedViewports = customViewports.length > 0;
36216
36566
  const slug = slugFromUrl(url);
36217
36567
  const ts2 = timestampSlug();
36218
36568
  const shotName = (vp2) => defaultFilename(slug, ts2, vp2 && userSpecifiedViewports ? dimSuffix(vp2) : void 0);
36219
36569
  const names = userSpecifiedViewports ? customViewports.map(shotName) : [shotName()];
36220
- const projectGuid = getConfig()?.projectGuid;
36221
- const save = !opts.ephemeral && projectGuid ? { project_guid: projectGuid, names } : void 0;
36570
+ const projectGuid2 = getConfig()?.projectGuid;
36571
+ const save = !opts.ephemeral && projectGuid2 ? { project_guid: projectGuid2, names } : void 0;
36222
36572
  let camera;
36223
36573
  if (opts.camera) {
36224
36574
  assertCameraFile(opts.camera);
36225
- if (!projectGuid) throw new Error("--camera needs a linked project (the frame is hosted for the browser to fetch) \u2014 run `gipity link` first.");
36575
+ if (!projectGuid2) throw new Error("--camera needs a linked project (the frame is hosted for the browser to fetch) \u2014 run `gipity link` first.");
36226
36576
  console.log(muted(`Hosting camera feed ${opts.camera}...`));
36227
- camera = await uploadCameraFeed(projectGuid, opts.camera);
36577
+ camera = await uploadCameraFeed(projectGuid2, opts.camera);
36228
36578
  }
36229
36579
  const preCapture = [
36230
36580
  opts.waitFor ? buildWaitForGate(opts.waitFor, waitForTimeoutMs) : "",
@@ -36255,7 +36605,7 @@ var pageScreenshotCommand = new Command("screenshot").description("Screenshot a
36255
36605
  } finally {
36256
36606
  if (camera) {
36257
36607
  try {
36258
- await deleteFixture(projectGuid, camera.guid);
36608
+ await deleteFixture(projectGuid2, camera.guid);
36259
36609
  } catch (err) {
36260
36610
  console.error(warning(`\u26A0 Could not auto-delete camera feed "${camera.name}" (${camera.guid}) \u2014 still hosted at ${camera.url}: ${err.message}`));
36261
36611
  }
@@ -36270,8 +36620,16 @@ var pageScreenshotCommand = new Command("screenshot").description("Screenshot a
36270
36620
  }
36271
36621
  const dir = defaultScreenshotDir();
36272
36622
  const savedFiles = [];
36623
+ const usedStems = /* @__PURE__ */ new Set();
36624
+ const outputPath = (i) => {
36625
+ if (pngs.length === 1) return opts.output;
36626
+ let suffix = viewportLabels[i] ?? dimSuffix(meta.screenshots[i].viewport);
36627
+ while (usedStems.has(suffix)) suffix += `-${i + 1}`;
36628
+ usedStems.add(suffix);
36629
+ return stemPath(opts.output, suffix);
36630
+ };
36273
36631
  for (let i = 0; i < pngs.length; i++) {
36274
- const target = opts.output ? opts.output : join12(dir, names[i] ?? shotName(meta.screenshots[i].viewport));
36632
+ const target = opts.output ? outputPath(i) : join12(dir, names[i] ?? shotName(meta.screenshots[i].viewport));
36275
36633
  mkdirSync7(dirname7(target), { recursive: true });
36276
36634
  writeFileSync8(target, pngs[i].buffer);
36277
36635
  savedFiles.push(resolvePath(target));
@@ -36757,10 +37115,10 @@ dbCommand.command("list").description("List databases").option("--all", "List da
36757
37115
  grouped.get(key).push(db2);
36758
37116
  }
36759
37117
  let first = true;
36760
- for (const [projectGuid, dbs] of grouped) {
37118
+ for (const [projectGuid2, dbs] of grouped) {
36761
37119
  if (!first) console.log("");
36762
37120
  first = false;
36763
- const label2 = dbs[0].projectSlug || dbs[0].projectName || projectGuid;
37121
+ const label2 = dbs[0].projectSlug || dbs[0].projectName || projectGuid2;
36764
37122
  console.log(label2);
36765
37123
  for (const db2 of dbs) {
36766
37124
  console.log(`${db2.friendlyName}`);
@@ -36816,6 +37174,47 @@ dbCommand.command("drop <name>").description("Drop a database").option("--projec
36816
37174
  console.log(success(`Dropped database '${name}'.`));
36817
37175
  }
36818
37176
  }));
37177
+ dbCommand.command("checkpoint").description("Snapshot every table so a write test can be undone. Take one before exercising a real write path (page eval on the deployed app, fn call, workflow run), then `gipity db restore` to put the data back exactly as it was - no hand-written SQL to scrub test strings out of real content.").option("--database <name>", "Database name").option("--drop", "Discard the existing checkpoint without restoring (keep whatever the run wrote)").option("--json", "Output as JSON").action((opts) => run("Checkpoint", async () => {
37178
+ const config = requireConfig();
37179
+ const dbName = await resolveDatabase(config.projectGuid, opts.database);
37180
+ if (!dbName) {
37181
+ console.error(error("No databases in this project - nothing to checkpoint."));
37182
+ process.exit(1);
37183
+ }
37184
+ if (opts.drop) {
37185
+ const dropped = await dropCheckpoint(config.projectGuid, dbName);
37186
+ if (opts.json) {
37187
+ console.log(JSON.stringify(dropped));
37188
+ } else {
37189
+ console.log(dropped.tables.length === 0 ? `No checkpoint in '${dbName}'.` : success(`Discarded the checkpoint of ${dropped.tables.length} table(s) in '${dbName}'. Current data kept.`));
37190
+ }
37191
+ return;
37192
+ }
37193
+ const res = await createCheckpoint(config.projectGuid, dbName);
37194
+ if (opts.json) {
37195
+ console.log(JSON.stringify(res));
37196
+ } else if (res.tables.length === 0) {
37197
+ console.log(`Database '${dbName}' has no tables - nothing to checkpoint.`);
37198
+ } else {
37199
+ console.log(success(`Checkpointed ${res.tables.length} table(s), ${res.rows} row(s) in '${dbName}'.`));
37200
+ console.log("Undo everything written since: gipity db restore");
37201
+ }
37202
+ }));
37203
+ dbCommand.command("restore").description("Roll every table back to the last `gipity db checkpoint` and drop the checkpoint. The undo for a live write test.").option("--database <name>", "Database name").option("--keep", "Keep the checkpoint after restoring, so you can run the same write test again").option("--json", "Output as JSON").action((opts) => run("Restore", async () => {
37204
+ const config = requireConfig();
37205
+ const dbName = await resolveDatabase(config.projectGuid, opts.database);
37206
+ if (!dbName) {
37207
+ console.error(error("No databases in this project - nothing to restore."));
37208
+ process.exit(1);
37209
+ }
37210
+ const res = await restoreCheckpoint(config.projectGuid, dbName, { keep: opts.keep });
37211
+ if (opts.json) {
37212
+ console.log(JSON.stringify(res));
37213
+ } else {
37214
+ console.log(success(`Restored ${res.tables.length} table(s) to the checkpoint (${res.rows} row(s)) in '${dbName}'.`));
37215
+ if (opts.keep) console.log("Checkpoint kept - restore again any time.");
37216
+ }
37217
+ }));
36819
37218
 
36820
37219
  // src/commands/memory.ts
36821
37220
  init_api();
@@ -36870,7 +37269,7 @@ memoryCommand.command("delete <topic>").description("Delete a topic").option("--
36870
37269
  init_api();
36871
37270
  init_config();
36872
37271
  import { readFileSync as readFileSync15, existsSync as existsSync11, statSync as statSync7, mkdirSync as mkdirSync8, writeFileSync as writeFileSync9 } from "fs";
36873
- import { dirname as dirname8, extname as extname4, relative as relative4, resolve as resolve11, sep as sep3 } from "path";
37272
+ import { dirname as dirname8, extname as extname5, relative as relative4, resolve as resolve11, sep as sep3 } from "path";
36874
37273
  init_colors();
36875
37274
  var LANG_MAP = {
36876
37275
  js: "javascript",
@@ -36934,7 +37333,7 @@ function resolveLanguage(opts) {
36934
37333
  process.exit(1);
36935
37334
  }
36936
37335
  if (explicit) return explicit;
36937
- const fromExt = opts.filePath ? LANG_MAP[extname4(opts.filePath).slice(1).toLowerCase()] : void 0;
37336
+ const fromExt = opts.filePath ? LANG_MAP[extname5(opts.filePath).slice(1).toLowerCase()] : void 0;
36938
37337
  if (fromExt) return fromExt;
36939
37338
  if (opts.filePath) {
36940
37339
  console.error(error(`Cannot infer the language of ${opts.filePath} from its extension (expected .js, .py, or .sh).`));
@@ -37645,6 +38044,33 @@ function formatRunLine(r) {
37645
38044
  const statusColor = r.status === "completed" ? success : r.status === "failed" ? error : muted;
37646
38045
  return `${muted(r.short_guid)} ${statusColor(r.status)} ${dur} ${runTokens(r)} tokens ${muted(fmtTime(r.started_at))}`;
37647
38046
  }
38047
+ function jsonStringInfo(v7) {
38048
+ if (typeof v7 !== "string") return null;
38049
+ const t = v7.trim();
38050
+ if (!t.startsWith("{") && !t.startsWith("[")) return null;
38051
+ try {
38052
+ const parsed = JSON.parse(t);
38053
+ return typeof parsed === "object" && parsed !== null ? { ok: true, value: parsed } : null;
38054
+ } catch {
38055
+ return { ok: false };
38056
+ }
38057
+ }
38058
+ function labelFor(info2) {
38059
+ return info2.ok ? "(JSON string)" : error("(truncated/invalid JSON string)");
38060
+ }
38061
+ function renderStepOutput(output) {
38062
+ if (output && typeof output === "object" && !Array.isArray(output)) {
38063
+ return Object.entries(output).map(([k7, v7]) => {
38064
+ const info3 = jsonStringInfo(v7);
38065
+ if (!info3) return `${JSON.stringify(k7)}: ${JSON.stringify(v7, null, 2)}`;
38066
+ const body = info3.ok ? JSON.stringify(info3.value, null, 2) : JSON.stringify(v7);
38067
+ return `${JSON.stringify(k7)} ${labelFor(info3)}: ${body}`;
38068
+ }).join("\n");
38069
+ }
38070
+ const info2 = jsonStringInfo(output);
38071
+ if (info2?.ok) return JSON.stringify(info2.value, null, 2);
38072
+ return JSON.stringify(output, null, 2);
38073
+ }
37648
38074
  function printStepRuns(steps, emptyNote) {
37649
38075
  if (steps.length === 0) {
37650
38076
  console.log(` ${muted(emptyNote)}`);
@@ -37657,7 +38083,7 @@ function printStepRuns(steps, emptyNote) {
37657
38083
  console.log(` ${s.step_order}. ${name}${statusColor(s.status)} ${s.tokens_used ?? 0} tokens${model}`);
37658
38084
  if (s.error_message) console.log(` ${error(s.error_message)}`);
37659
38085
  if (s.output_json !== null && s.output_json !== void 0) {
37660
- console.log(JSON.stringify(s.output_json, null, 2).split("\n").map((l7) => ` ${l7}`).join("\n"));
38086
+ console.log(renderStepOutput(s.output_json).split("\n").map((l7) => ` ${l7}`).join("\n"));
37661
38087
  }
37662
38088
  }
37663
38089
  }
@@ -37728,7 +38154,7 @@ workflowCommand.command("info <name>").description("Show workflow details").opti
37728
38154
  }
37729
38155
  }
37730
38156
  }));
37731
- workflowCommand.command("run <name>").description("Trigger a workflow (add --wait to block until it finishes)").option("--json", "Output as JSON").option("--wait", "Block until the triggered run reaches a terminal state, then print it").option("--timeout <s>", "Max seconds to wait with --wait", "120").action((name, _opts, cmd) => run("Run", async () => {
38157
+ workflowCommand.command("run <name>").description("Run a workflow: waits for it to finish and prints each step's status and output (--no-wait to fire and forget)").option("--json", "Output as JSON").option("--no-wait", "Trigger and return immediately instead of waiting for the run").addOption(new Option("--wait", "Wait for the run (already the default)").hideHelp()).option("--timeout <s>", "Max seconds to wait for the run to finish", "300").action((name, _opts, cmd) => run("Run", async () => {
37732
38158
  const opts = mergedOpts(cmd);
37733
38159
  const wf2 = await resolveWorkflow(name);
37734
38160
  if (!opts.wait) {
@@ -37739,7 +38165,7 @@ workflowCommand.command("run <name>").description("Trigger a workflow (add --wai
37739
38165
  const before = await get(`/workflows/${wf2.short_guid}/runs?limit=1`);
37740
38166
  const prevGuid = before.data[0]?.short_guid;
37741
38167
  await post(`/workflows/${wf2.short_guid}/run`, {});
37742
- const r = await waitForRun(wf2.short_guid, prevGuid, Number(opts.timeout) || 120);
38168
+ const r = await waitForRun(wf2.short_guid, prevGuid, Number(opts.timeout) || 300);
37743
38169
  if (opts.json) {
37744
38170
  console.log(JSON.stringify(r));
37745
38171
  } else {
@@ -38751,7 +39177,7 @@ async function runRelaySetup(opts) {
38751
39177
  }
38752
39178
  console.log("");
38753
39179
  const promptText = opts.mode === "run-now" ? " Set up remote control on this computer" : " Enable remote control";
38754
- const enable = await confirm(promptText, { default: "yes" });
39180
+ const enable = await confirm(promptText, { default: "yes", headless: "no" });
38755
39181
  if (!enable) {
38756
39182
  setRelayEnabled(false);
38757
39183
  console.log(` ${muted("Skipped. Turn on later with")} ${brand("gipity connect")}${muted(".")}`);
@@ -38778,7 +39204,7 @@ async function runRelaySetup(opts) {
38778
39204
  }
38779
39205
  startDaemon();
38780
39206
  console.log("");
38781
- const autostartOs = await confirm(" Also start at OS login", { default: "yes" });
39207
+ const autostartOs = await confirm(" Also start at OS login", { default: "yes", headless: "no" });
38782
39208
  if (autostartOs) {
38783
39209
  try {
38784
39210
  const res = installAutostart();
@@ -38801,7 +39227,7 @@ async function runRelaySetup(opts) {
38801
39227
  }
38802
39228
  if (getDiagnosticsConsent() === void 0) {
38803
39229
  console.log("");
38804
- const diag = await confirm(" Share anonymous diagnostics info", { default: "yes" });
39230
+ const diag = await confirm(" Share anonymous diagnostics info", { default: "yes", headless: "no" });
38805
39231
  setDiagnosticsConsent(diag);
38806
39232
  }
38807
39233
  console.log("");
@@ -39240,11 +39666,53 @@ var grokAdapter = {
39240
39666
  installHint: "curl -fsSL https://grok.x.ai/install.sh | sh (or see docs.x.ai/grok-build)"
39241
39667
  };
39242
39668
 
39669
+ // src/agents/agy.ts
39670
+ var MODEL_ID_TO_AGY_DISPLAY = {
39671
+ "gemini-3.5-flash": "Gemini 3.5 Flash (Medium)",
39672
+ "gemini-3.1-pro-preview": "Gemini 3.1 Pro (High)"
39673
+ };
39674
+ function translateModel(model) {
39675
+ return MODEL_ID_TO_AGY_DISPLAY[model] ?? model;
39676
+ }
39677
+ function projectArgs(resume) {
39678
+ return resume ? ["--conversation", resume] : ["--new-project"];
39679
+ }
39680
+ var agyAdapter = {
39681
+ key: "agy",
39682
+ source: "agy",
39683
+ displayName: "Antigravity",
39684
+ providerName: "Google",
39685
+ binary: "agy",
39686
+ buildInteractiveArgs({ resume, model }) {
39687
+ const args = [...projectArgs(resume)];
39688
+ if (model) args.push("--model", translateModel(model));
39689
+ return args;
39690
+ },
39691
+ buildHeadlessArgs({ message, resume, model, bypassApprovals }) {
39692
+ const args = ["-p", message, ...projectArgs(resume)];
39693
+ if (model) args.push("--model", translateModel(model));
39694
+ if (bypassApprovals) args.push("--dangerously-skip-permissions");
39695
+ return args;
39696
+ },
39697
+ sessionIdFromStreamEvent() {
39698
+ return null;
39699
+ },
39700
+ hooksSupportedOnPlatform(platform2) {
39701
+ return platform2 !== "win32";
39702
+ },
39703
+ // No stream-json ingest mapper for agy: hook capture carries the full
39704
+ // conversationId on every event (confirmed live, including headless -p),
39705
+ // so the daemon doesn't need to parse agy's stdout at all.
39706
+ daemonStreamCapture: false,
39707
+ installHint: "see https://antigravity.google (Google account sign-in required)"
39708
+ };
39709
+
39243
39710
  // src/agents/index.ts
39244
39711
  var AGENT_ADAPTERS = [
39245
39712
  claudeCodeAdapter,
39246
39713
  codexAdapter,
39247
- grokAdapter
39714
+ grokAdapter,
39715
+ agyAdapter
39248
39716
  ];
39249
39717
  var AGENT_KEYS = AGENT_ADAPTERS.map((a) => a.key);
39250
39718
  function getAdapter(key) {
@@ -39316,10 +39784,10 @@ function reportSyncResult(result) {
39316
39784
  console.log(` ${muted("Nothing was deleted. Re-run `gipity sync` to finish the pull.")}`);
39317
39785
  }
39318
39786
  }
39319
- async function fetchProjectStats(projectGuid, cwd) {
39320
- if (projectGuid) {
39787
+ async function fetchProjectStats(projectGuid2, cwd) {
39788
+ if (projectGuid2) {
39321
39789
  try {
39322
- const res = await get(`/projects/${encodeURIComponent(projectGuid)}/files/stats`);
39790
+ const res = await get(`/projects/${encodeURIComponent(projectGuid2)}/files/stats`);
39323
39791
  const d = res.data;
39324
39792
  const topLevelNames = d.top_level.map((e) => e.type === "directory" ? `${e.name}/` : e.name);
39325
39793
  return {
@@ -39913,7 +40381,7 @@ async function pickAgent(lastUsed) {
39913
40381
  AGENT_ADAPTERS.forEach((a, i) => {
39914
40382
  const notes = [];
39915
40383
  if (a.key === lastUsed) notes.push("last used");
39916
- if (a.key === "codex" && !a.hooksSupportedOnPlatform(process.platform)) {
40384
+ if ((a.key === "codex" || a.key === "agy") && !a.hooksSupportedOnPlatform(process.platform)) {
39917
40385
  notes.push("session recording unavailable on Windows");
39918
40386
  }
39919
40387
  const note = notes.length ? ` ${muted(`(${notes.join(", ")})`)}` : "";
@@ -40286,9 +40754,9 @@ var STARTERS = [
40286
40754
  ];
40287
40755
  var BLANK = [
40288
40756
  { key: "web-simple", hint: "static frontend-only site - pages, dashboards, simple games" },
40289
- { key: "web-fullstack", hint: "backend API + database wiring - frontend, functions, migrations; deploys green" },
40757
+ { key: "web-fullstack", hint: "backend API + database wiring - frontend, functions, migrations; deploys green empty" },
40290
40758
  { key: "3d-engine", hint: "3D multiplayer wiring - Three.js + Rapier + Gipity Realtime" },
40291
- { key: "api", hint: "pure API backend, no frontend - one example function + test" }
40759
+ { key: "api", hint: "pure API backend, no frontend - functions + tests, deploys green" }
40292
40760
  ];
40293
40761
  var KITS = [
40294
40762
  { key: "realtime", hint: "multiplayer / presence / shared state" },
@@ -40490,6 +40958,87 @@ Pulled ${syncResult.applied} files to local.`);
40490
40958
  }
40491
40959
  }));
40492
40960
 
40961
+ // src/commands/brand.ts
40962
+ init_api();
40963
+ init_config();
40964
+ init_colors();
40965
+ function printBrand(d) {
40966
+ console.log(`${bold("Glyph:")} ${d.resolved.glyph}${d.resolved.isEmoji ? muted(" (emoji)") : ""}`);
40967
+ console.log(`${bold("Accent:")} ${d.resolved.accent}${d.branding.primary_color ? "" : muted(" (derived from project)")}`);
40968
+ if (d.branding.app_name) console.log(`${bold("Name:")} ${d.branding.app_name}`);
40969
+ if (d.branding.tagline) console.log(`${bold("Tagline:")} ${d.branding.tagline}`);
40970
+ }
40971
+ var DEPLOY_HINT = "Deploy to publish the new assets: gipity deploy dev";
40972
+ var brandCommand = new Command("brand").description("The app's generated icons + share card (favicons, iOS icon, og-image)").addHelpText("after", `
40973
+ Examples:
40974
+ gipity brand Show the current brand
40975
+ gipity brand set --emoji \u{1F98D} Emoji icon + share card, regenerate all assets
40976
+ gipity brand set --glyph R --color "#3b82f6" Letter glyph on a blue accent
40977
+ gipity brand set --tagline "Bananas at 60fps" Subtitle on the share card
40978
+ gipity brand apply Re-render assets from the stored brand
40979
+ gipity brand apply --fix-head Migrate an older app: assets + splice the
40980
+ share/SEO tags into src/index.html
40981
+
40982
+ Assets are generated server-side into src/images/ (+ src/manifest.webmanifest)
40983
+ and are versioned like any other file - rollback restores previous ones.`);
40984
+ brandCommand.command("show", { isDefault: true }).description("Show the stored brand and the resolved render spec").option("--project <guid-or-slug>", "Target a specific project instead of cwd / Home").option("--json", "Output raw JSON").action((opts) => run("Brand", async () => {
40985
+ const { config } = await resolveProjectContext({ projectOverride: opts.project });
40986
+ const res = await get(`/projects/${config.projectGuid}/brand`);
40987
+ if (opts.json) {
40988
+ console.log(JSON.stringify(res.data));
40989
+ return;
40990
+ }
40991
+ printBrand(res.data);
40992
+ console.log(muted(`
40993
+ Assets: ${(res.data.assets ?? []).join(", ")}`));
40994
+ console.log(muted(`Change it: gipity brand set --emoji \u{1F3A8} | --glyph A | --color "#RRGGBB" | --tagline "..."`));
40995
+ }));
40996
+ brandCommand.command("set").description("Change brand fields and regenerate all assets").option("--emoji <emoji>", "Emoji glyph for the icon + share card (e.g. \u{1F98D})").option("--glyph <char>", "Letter/digit glyph (alias of --emoji, any single character)").option("--color <hex>", "Accent color, #RRGGBB").option("--name <name>", "Display name override (og/share title stays the page title)").option("--tagline <text>", "One-line subtitle on the share card (max 120 chars)").option("--fix-head", "Also splice the share/SEO head block into src/index.html (for apps installed before templates shipped it)").option("--no-regenerate", "Store the fields without re-rendering assets").option("--project <guid-or-slug>", "Target a specific project instead of cwd / Home").option("--json", "Output raw JSON").action((opts) => run("Brand", async () => {
40997
+ const { config } = await resolveProjectContext({ projectOverride: opts.project });
40998
+ const body = {};
40999
+ const glyph = opts.emoji ?? opts.glyph;
41000
+ if (glyph) body.icon_glyph = glyph;
41001
+ if (opts.color) body.primary_color = opts.color;
41002
+ if (opts.name) body.app_name = opts.name;
41003
+ if (opts.tagline) body.tagline = opts.tagline;
41004
+ if (opts.regenerate === false) body.regenerate = false;
41005
+ if (opts.fixHead) body.fix_head = true;
41006
+ if (!glyph && !opts.color && !opts.name && !opts.tagline) {
41007
+ console.error("Nothing to set - pass --emoji, --glyph, --color, --name, or --tagline. (gipity brand set --help; use gipity brand apply to just re-render)");
41008
+ process.exitCode = 1;
41009
+ return;
41010
+ }
41011
+ const res = await post(`/projects/${config.projectGuid}/brand`, body);
41012
+ if (opts.json) {
41013
+ console.log(JSON.stringify(res.data));
41014
+ return;
41015
+ }
41016
+ printBrand(res.data);
41017
+ reportFiles(res.data.files ?? []);
41018
+ }));
41019
+ brandCommand.command("apply").description("Re-render all brand assets from the stored brand (no field changes)").option("--fix-head", "Also splice the share/SEO head block into src/index.html (for apps installed before templates shipped it)").option("--project <guid-or-slug>", "Target a specific project instead of cwd / Home").option("--json", "Output raw JSON").action((opts) => run("Brand", async () => {
41020
+ const { config } = await resolveProjectContext({ projectOverride: opts.project });
41021
+ const body = opts.fixHead ? { fix_head: true } : {};
41022
+ const res = await post(`/projects/${config.projectGuid}/brand`, body);
41023
+ if (opts.json) {
41024
+ console.log(JSON.stringify(res.data));
41025
+ return;
41026
+ }
41027
+ reportFiles(res.data.files ?? []);
41028
+ }));
41029
+ function reportFiles(files) {
41030
+ if (!files.length) {
41031
+ console.log(muted("\nStored (assets not regenerated - run gipity brand apply)."));
41032
+ return;
41033
+ }
41034
+ console.log(success(`
41035
+ \u2713 Regenerated ${files.length} asset${files.length === 1 ? "" : "s"}.`));
41036
+ if (files.includes("src/index.html")) {
41037
+ console.log(success("\u2713 src/index.html head updated (page title/description preserved)."));
41038
+ }
41039
+ console.log(muted(` ${DEPLOY_HINT}`));
41040
+ }
41041
+
40493
41042
  // src/commands/remove.ts
40494
41043
  init_api();
40495
41044
  init_config();
@@ -40984,7 +41533,7 @@ async function runInteractive(url, observe, opts) {
40984
41533
  const samples = Math.min(30, Math.max(2, parseInt(opts.samples, 10) || 6));
40985
41534
  const settle = opts.waitFor ? 200 : 1e3;
40986
41535
  const labels = (opts.labels ? String(opts.labels).split(",").map((s) => s.trim()) : []).filter(Boolean);
40987
- const labelFor = (i) => labels[i] ?? `client-${i}`;
41536
+ const labelFor2 = (i) => labels[i] ?? `client-${i}`;
40988
41537
  warnUnknownTokens(unknownTokens(url, opts.action, observe));
40989
41538
  if (!opts.json) {
40990
41539
  console.log(`${brand("Page test")} ${muted("(interactive)")} ${bold(url)}`);
@@ -40994,16 +41543,16 @@ async function runInteractive(url, observe, opts) {
40994
41543
  for (let i = 0; i < clients; i++) {
40995
41544
  runs.push((async () => {
40996
41545
  await sleep3(i * stagger * 1e3);
40997
- if (!opts.json) console.log(muted(`client ${i} (${labelFor(i)}) joining`));
40998
- const clientUrl = subst(url, labelFor(i), i);
41546
+ if (!opts.json) console.log(muted(`client ${i} (${labelFor2(i)}) joining`));
41547
+ const clientUrl = subst(url, labelFor2(i), i);
40999
41548
  const expr = buildHarness(
41000
- opts.action ? subst(opts.action, labelFor(i), i) : void 0,
41001
- subst(observe, labelFor(i), i),
41002
- labelFor(i),
41549
+ opts.action ? subst(opts.action, labelFor2(i), i) : void 0,
41550
+ subst(observe, labelFor2(i), i),
41551
+ labelFor2(i),
41003
41552
  hold,
41004
41553
  samples
41005
41554
  );
41006
- return observeClient(clientUrl, expr, i, labelFor(i), settle, hold, opts.waitFor);
41555
+ return observeClient(clientUrl, expr, i, labelFor2(i), settle, hold, opts.waitFor);
41007
41556
  })());
41008
41557
  }
41009
41558
  const results = (await Promise.all(runs)).sort((a, b7) => a.i - b7.i);
@@ -41057,7 +41606,7 @@ async function runPassive(url, opts) {
41057
41606
  const stagger = opts.stagger != null ? Math.max(0, parseInt(opts.stagger, 10) || 0) : 12;
41058
41607
  const wait = Math.min(3e4, Math.max(2e3, parseInt(opts.wait, 10) || 24e3));
41059
41608
  const labels = (opts.labels ? String(opts.labels).split(",").map((s) => s.trim()) : []).filter(Boolean);
41060
- const labelFor = (i) => labels[i] ?? `client-${i}`;
41609
+ const labelFor2 = (i) => labels[i] ?? `client-${i}`;
41061
41610
  warnUnknownTokens(unknownTokens(url));
41062
41611
  if (!opts.json) {
41063
41612
  console.log(`${brand("Page test")} ${bold(url)}`);
@@ -41068,7 +41617,7 @@ async function runPassive(url, opts) {
41068
41617
  runs.push((async () => {
41069
41618
  await sleep3(i * stagger * 1e3);
41070
41619
  if (!opts.json) console.log(`${muted(`client ${i}${i === 0 ? " (first)" : ""} starting`)}`);
41071
- return inspectClient(subst(url, labelFor(i), i), wait, i);
41620
+ return inspectClient(subst(url, labelFor2(i), i), wait, i);
41072
41621
  })());
41073
41622
  }
41074
41623
  const results = (await Promise.all(runs)).sort((a, b7) => a.i - b7.i);
@@ -41262,10 +41811,31 @@ pageCommand.action(() => {
41262
41811
 
41263
41812
  // src/commands/records.ts
41264
41813
  init_api();
41814
+ init_auth();
41265
41815
  init_config();
41266
41816
  init_colors();
41267
41817
  init_utils();
41268
41818
  var recordsCommand = new Command("records").description("Manage app records (Gipity Records - validated CRUD with audit history)");
41819
+ var ANON_FLAG = "--anon";
41820
+ var ANON_HELP = "Call as an anonymous visitor (the public path a signed-out user hits) instead of as your signed-in account";
41821
+ async function recordsHttp(opts) {
41822
+ const config = requireConfig();
41823
+ if (!opts.anon) {
41824
+ const who = getAuth()?.email;
41825
+ console.error(muted(`Auth: calling as ${who ?? "your signed-in account"} (the owner persona; use ${ANON_FLAG} for the public visitor path)`));
41826
+ return { guid: config.projectGuid, get, post, put, patch, del };
41827
+ }
41828
+ console.error(muted("Auth: anonymous visitor (the public path a signed-out user hits)"));
41829
+ const headers = await mintAppToken(config.projectGuid);
41830
+ return {
41831
+ guid: config.projectGuid,
41832
+ get: (path5) => publicRequest("GET", path5, void 0, headers),
41833
+ post: (path5, body) => publicRequest("POST", path5, body, headers),
41834
+ put: (path5, body) => publicRequest("PUT", path5, body, headers),
41835
+ patch: (path5, body) => publicRequest("PATCH", path5, body, headers),
41836
+ del: (path5, body) => publicRequest("DELETE", path5, body, headers)
41837
+ };
41838
+ }
41269
41839
  recordsCommand.command("list").description("List record tables").option("--json", "Output as JSON").action((opts) => run("List", async () => {
41270
41840
  const config = requireConfig();
41271
41841
  const res = await get(`/api/${config.projectGuid}/records-config`);
@@ -41297,16 +41867,20 @@ recordsCommand.command("config <table>").description("Show or set a table's Reco
41297
41867
  res.data
41298
41868
  );
41299
41869
  }));
41300
- recordsCommand.command("query <table>").description("List records").option("--filter <filter>", 'Filter string (e.g., "status:eq:active")').option("--sort <sort>", 'Sort string (e.g., "created_at:desc")').option("--limit <n>", "Max rows", "20").option("--offset <n>", "Offset", "0").option("--fields <fields>", "Comma-separated column names").option("--json", "Output as JSON").action((table, opts) => run("Query", async () => {
41301
- const config = requireConfig();
41870
+ recordsCommand.command("query <table>").description("List records (full-text search, filter, sort, recycle bin)").option("-q, --q <text>", "Full-text search across the table's text columns (needs `--searchable true` on the table)").option("--filter <filter>", 'AND filter string (e.g., "status:eq:active")').option("--any <filter>", 'OR filter group, same grammar as --filter (e.g., "owner:eq:me,shared:eq:true")').option("--sort <sort>", 'Sort string (e.g., "created_at:desc")').option("--limit <n>", "Max rows", "20").option("--offset <n>", "Offset", "0").option("--fields <fields>", "Comma-separated column names").option("--include-deleted", "Include soft-deleted rows (owner/editor)").option("--only-deleted", "Only soft-deleted rows - the recycle bin (owner/editor)").option(ANON_FLAG, ANON_HELP).option("--json", "Output as JSON").action((table, opts) => run("Query", async () => {
41871
+ const api = await recordsHttp(opts);
41302
41872
  const params = new URLSearchParams();
41873
+ if (opts.q) params.set("q", opts.q);
41303
41874
  if (opts.filter) params.set("filter", opts.filter);
41875
+ if (opts.any) params.set("any", opts.any);
41304
41876
  if (opts.sort) params.set("sort", opts.sort);
41305
41877
  params.set("limit", opts.limit);
41306
41878
  params.set("offset", opts.offset);
41307
41879
  if (opts.fields) params.set("fields", opts.fields);
41308
- const res = await get(
41309
- `/api/${config.projectGuid}/records/${table}?${params}`
41880
+ if (opts.includeDeleted) params.set("include_deleted", "1");
41881
+ if (opts.onlyDeleted) params.set("only_deleted", "1");
41882
+ const res = await api.get(
41883
+ `/api/${api.guid}/records/${table}?${params}`
41310
41884
  );
41311
41885
  if (opts.json) {
41312
41886
  console.log(JSON.stringify(res));
@@ -41319,41 +41893,55 @@ recordsCommand.command("query <table>").description("List records").option("--fi
41319
41893
  console.log("");
41320
41894
  }
41321
41895
  }));
41322
- recordsCommand.command("get <table> <id>").description("Get a record").option("--json", "Output as JSON").action((table, id2, opts) => run("Get", async () => {
41323
- const config = requireConfig();
41324
- const res = await get(`/api/${config.projectGuid}/records/${table}/${id2}`);
41896
+ recordsCommand.command("get <table> <id>").description("Get a record").option(ANON_FLAG, ANON_HELP).option("--json", "Output as JSON").action((table, id2, opts) => run("Get", async () => {
41897
+ const api = await recordsHttp(opts);
41898
+ const res = await api.get(`/api/${api.guid}/records/${table}/${id2}`);
41325
41899
  console.log(opts.json ? JSON.stringify(res.data) : JSON.stringify(res.data, null, 2));
41326
41900
  }));
41327
- recordsCommand.command("history <table> <id>").description("Audit history for a record (who/what changed it, with English summaries)").option("--limit <n>", "Max events", "20").option("--json", "Output as JSON").action((table, id2, opts) => run("History", async () => {
41328
- const config = requireConfig();
41329
- const res = await get(
41330
- `/api/${config.projectGuid}/records/${table}/${id2}/history?limit=${encodeURIComponent(opts.limit)}`
41901
+ recordsCommand.command("history <table> [id]").description("Audit history (who/what changed it, with English summaries). Omit <id> for the whole table's feed.").option("--limit <n>", "Max events", "20").option(ANON_FLAG, ANON_HELP).option("--json", "Output as JSON").action((table, id2, opts) => run("History", async () => {
41902
+ const api = await recordsHttp(opts);
41903
+ const scope = id2 ? `${table}/${encodeURIComponent(id2)}` : table;
41904
+ const res = await api.get(
41905
+ `/api/${api.guid}/records/${scope}/history?limit=${encodeURIComponent(opts.limit)}`
41331
41906
  );
41332
- printList(res.data, opts, "No history for this record.", (e) => {
41907
+ printList(res.data, opts, id2 ? "No history for this record." : `No history for "${table}".`, (e) => {
41333
41908
  const summary = e.detail?.summary || `${e.action} ${e.entity_type} ${e.entity_id}`;
41334
41909
  return `${muted(e.created_at)} ${bold(e.source || "-")} ${summary}`;
41335
41910
  });
41336
41911
  }));
41337
- recordsCommand.command("create <table>").description("Create a record").requiredOption("--data <json>", "JSON object with field values").option("--json", "Output as JSON").action((table, opts) => run("Create", async () => {
41338
- const config = requireConfig();
41912
+ recordsCommand.command("create <table>").description("Create a record").requiredOption("--data <json>", "JSON object with field values").option(ANON_FLAG, ANON_HELP).option("--json", "Output as JSON").action((table, opts) => run("Create", async () => {
41913
+ const api = await recordsHttp(opts);
41339
41914
  const data = JSON.parse(opts.data);
41340
- const res = await post(`/api/${config.projectGuid}/records/${table}`, data);
41915
+ const res = await api.post(`/api/${api.guid}/records/${table}`, data);
41341
41916
  printResult(`Created: ${JSON.stringify(res.data)}`, opts, res.data);
41342
41917
  }));
41343
- recordsCommand.command("update <table> <id>").description("Update a record").requiredOption("--data <json>", "JSON object with fields to update").option("--json", "Output as JSON").action((table, id2, opts) => run("Update", async () => {
41344
- const config = requireConfig();
41918
+ recordsCommand.command("update <table> <id>").description("Update a record").requiredOption("--data <json>", "JSON object with fields to update").option(ANON_FLAG, ANON_HELP).option("--json", "Output as JSON").action((table, id2, opts) => run("Update", async () => {
41919
+ const api = await recordsHttp(opts);
41345
41920
  const data = JSON.parse(opts.data);
41346
- const res = await put(`/api/${config.projectGuid}/records/${table}/${id2}`, data);
41921
+ const res = await api.put(`/api/${api.guid}/records/${table}/${id2}`, data);
41347
41922
  printResult(`Updated: ${JSON.stringify(res.data)}`, opts, res.data);
41348
41923
  }));
41349
- recordsCommand.command("delete <table> <id>").description("Delete a record").action((table, id2) => run("Delete", async () => {
41350
- if (!await confirm(`Delete record ${id2} from "${table}"?`)) {
41351
- console.log("Cancelled.");
41924
+ recordsCommand.command("delete <table> <id>").description("Delete a record (soft-delete when the table declares one; --purge removes it for good)").option("--purge", "Hard-delete: remove the row AND erase its audit history (owner/editor). Use it to scrub probe rows.").option(ANON_FLAG, ANON_HELP).option("--json", "Output as JSON").action((table, id2, opts) => run("Delete", async () => {
41925
+ const what = opts.purge ? `Purge record ${id2} from "${table}" (also erases its history)?` : `Delete record ${id2} from "${table}"?`;
41926
+ if (!await confirm(what)) {
41927
+ printResult("Cancelled.", opts, { table, id: id2, deleted: false, cancelled: true });
41352
41928
  return;
41353
41929
  }
41354
- const config = requireConfig();
41355
- await del(`/api/${config.projectGuid}/records/${table}/${id2}`);
41356
- printResult("Deleted.", { json: false });
41930
+ const api = await recordsHttp(opts);
41931
+ const res = await api.del(
41932
+ `/api/${api.guid}/records/${table}/${id2}${opts.purge ? "?purge=1" : ""}`
41933
+ );
41934
+ const data = res?.data ?? { table, id: id2, deleted: true, purged: !!opts.purge };
41935
+ printResult(
41936
+ data.purged ? "Purged - the row and its audit history are gone." : `Deleted. If "${table}" declares a soft-delete column the row is only stamped deleted (see it with \`gipity records query ${table} --only-deleted\`, bring it back with \`gipity records restore ${table} ${id2}\`, remove it for good with \`--purge\`).`,
41937
+ opts,
41938
+ data
41939
+ );
41940
+ }));
41941
+ recordsCommand.command("restore <table> <id>").description("Un-delete a soft-deleted record (owner/editor)").option("--json", "Output as JSON").action((table, id2, opts) => run("Restore", async () => {
41942
+ const api = await recordsHttp(opts);
41943
+ const res = await api.post(`/api/${api.guid}/records/${table}/${id2}/restore`);
41944
+ printResult("Restored.", opts, res?.data ?? { table, id: id2, restored: true });
41357
41945
  }));
41358
41946
 
41359
41947
  // src/commands/fn.ts
@@ -41398,17 +41986,11 @@ ${error(`error: ${log3.error_message}`)}`;
41398
41986
  return line;
41399
41987
  });
41400
41988
  }));
41401
- async function callAnon(projectGuid, name, body) {
41402
- let appToken;
41403
- try {
41404
- const minted = await publicPost("/api/token", { app: projectGuid });
41405
- appToken = minted.data.token;
41406
- } catch {
41407
- }
41989
+ async function callAnon(projectGuid2, name, body) {
41408
41990
  return publicPost(
41409
- `/api/${projectGuid}/fn/${encodeURIComponent(name)}`,
41991
+ `/api/${projectGuid2}/fn/${encodeURIComponent(name)}`,
41410
41992
  body,
41411
- appToken ? { "X-App-Token": appToken } : void 0
41993
+ await mintAppToken(projectGuid2)
41412
41994
  );
41413
41995
  }
41414
41996
  fnCommand.command("call <name> [body]").description("Call a function").option("-d, --data <json>", "JSON request body: inline JSON, @file to read a file, or @- / - for stdin").option("--file <field=@path>", "Attach a file as { data, media_type } under <field> (the vision/media service shape), repeatable, e.g. --file image=@receipt.png", (v7, acc) => (acc || []).concat(v7)).option("--anon", "Call as an anonymous visitor (the public path a signed-out user hits) instead of as your signed-in account").option("--field <path>", "Print only this field of the result (dot path, e.g. items.0.short_guid)").option("--json", "Output as JSON").action((name, bodyArg, opts) => run("Call", async () => {
@@ -41446,6 +42028,7 @@ fnCommand.command("delete <name>").alias("rm").description("Delete a function").
41446
42028
 
41447
42029
  // src/commands/service.ts
41448
42030
  init_api();
42031
+ init_auth();
41449
42032
  init_config();
41450
42033
  init_colors();
41451
42034
  var SERVICES = [
@@ -41471,16 +42054,59 @@ serviceCommand.command("list").description("List callable app services").option(
41471
42054
  const width = SERVICES.reduce((m, s) => Math.max(m, s.name.length), 0);
41472
42055
  console.log('Call one with `gipity service call <name> \'{"json":"body"}\'`');
41473
42056
  console.log(muted("(GET endpoints like llm/models, tts/voices take --get and no body)"));
42057
+ console.log(muted("Verifying what a signed-out visitor of your deployed app gets? Add --anon."));
41474
42058
  console.log("");
41475
42059
  for (const s of SERVICES) {
41476
42060
  console.log(`${bold(s.name.padEnd(width))} ${muted(`[${s.method}] ${s.desc}`)}`);
41477
42061
  }
41478
42062
  }));
41479
- serviceCommand.command("call <name> [body]").description("Call an app service by name (e.g. llm, image, music). Uses your logged-in session - no token wrangling.").option("--data <json>", "JSON request body (alternative to the positional [body])").option("--get", "Issue a GET (for listing endpoints like llm/models, tts/voices)").option("--json", "Output compact JSON").action((name, bodyArg, opts) => run("Call", async () => {
42063
+ function anonBillingHint(name, err) {
42064
+ if (!(err instanceof ApiError) || err.statusCode !== 401) return null;
42065
+ if (err.code !== "LOGIN_REQUIRED") return null;
42066
+ const service = name.split("/")[0];
42067
+ return `An anonymous visitor cannot call '${service}': it is on the user_pays default, so every signed-out visitor to a public page is asked to sign in and pay from their own credits. If this app serves the public (a help bubble, a landing page), switch it to owner-pays in gipity.yaml and redeploy:
42068
+ deploy:
42069
+ phases:
42070
+ - services
42071
+ service_definitions:
42072
+ - service: ${service}
42073
+ billing_mode: owner_pays
42074
+ gipity deploy dev --only services`;
42075
+ }
42076
+ serviceCommand.command("call <name> [body]").description("Call an app service by name (e.g. llm, image, music). Uses your logged-in session - no token wrangling; --anon calls it as a signed-out visitor instead.").option("-d, --data <json>", "JSON request body: inline JSON, @file to read a file, or @- / - for stdin (alternative to the positional [body])").option("--file <field=@path>", "Attach a file as { data, media_type } under <field> (the vision/media shape these services expect), repeatable, e.g. --file image=@receipt.png", (v7, acc) => (acc || []).concat(v7)).option("--anon", "Call as an anonymous visitor (the public path a signed-out user of your deployed app hits) instead of as your signed-in account").option("--get", "Issue a GET (for listing endpoints like llm/models, tts/voices)").option("--field <path>", "Print only this field of the result (dot path, e.g. choices.0.message.content)").option("--json", "Output compact JSON").action((name, bodyArg, opts) => run("Call", async () => {
41480
42077
  const config = requireConfig();
41481
42078
  const path5 = name.split("/").map(encodeURIComponent).join("/");
41482
42079
  const url = `/api/${config.projectGuid}/services/${path5}`;
41483
- const res = opts.get ? await get(url) : await post(url, JSON.parse(bodyArg || opts.data || "{}"));
42080
+ const body = resolveBody(bodyArg || opts.data, opts.file);
42081
+ if (opts.anon) {
42082
+ console.error(muted("Auth: anonymous visitor (the public path a signed-out user of your deployed app hits)"));
42083
+ } else {
42084
+ const who = getAuth()?.email;
42085
+ console.error(muted(`Auth: calling as ${who ?? "your signed-in account"} (the owner persona - always billed to you and never blocked by billing_mode; use --anon for the public visitor path)`));
42086
+ }
42087
+ let res;
42088
+ try {
42089
+ if (opts.anon) {
42090
+ const headers = await mintAppToken(config.projectGuid);
42091
+ if (!headers) {
42092
+ throw new Error(
42093
+ `Could not mint a visitor token for this project, so there is no anonymous path to call. That means the app is not deployed on this API base yet: run \`gipity deploy dev\` first, then re-run this command.`
42094
+ );
42095
+ }
42096
+ res = await publicRequest(opts.get ? "GET" : "POST", url, opts.get ? void 0 : body, headers);
42097
+ } else {
42098
+ res = opts.get ? await get(url) : await post(url, body);
42099
+ }
42100
+ } catch (err) {
42101
+ const hint = opts.anon ? anonBillingHint(name, err) : null;
42102
+ if (!hint) throw err;
42103
+ throw new Error(`${err.message}
42104
+ ${hint}`);
42105
+ }
42106
+ if (opts.field) {
42107
+ emitField(res, opts.field);
42108
+ return;
42109
+ }
41484
42110
  console.log(opts.json ? JSON.stringify(res) : JSON.stringify(res, null, 2));
41485
42111
  }));
41486
42112
 
@@ -42845,7 +43471,7 @@ function undeployedFunctionsFromFailures(results) {
42845
43471
  return [...names];
42846
43472
  }
42847
43473
  var LONG_RUN_MS = 6e4;
42848
- async function pollTestStatus(projectGuid, runGuid, opts) {
43474
+ async function pollTestStatus(projectGuid2, runGuid, opts) {
42849
43475
  const startTime = Date.now();
42850
43476
  const getPollInterval = () => {
42851
43477
  const elapsed = Date.now() - startTime;
@@ -42867,7 +43493,7 @@ async function pollTestStatus(projectGuid, runGuid, opts) {
42867
43493
  if (Date.now() - startTime > POLL_HARD_CAP_MS) {
42868
43494
  throw new Error(`Test run ${runGuid} still not finished after ${Math.round(POLL_HARD_CAP_MS / 6e4)} minutes - giving up on the poll. Check it later with \`gipity test status ${runGuid}\` or re-run.`);
42869
43495
  }
42870
- const res = await get(`/projects/${projectGuid}/test/status/${runGuid}`);
43496
+ const res = await get(`/projects/${projectGuid2}/test/status/${runGuid}`);
42871
43497
  const data = res.data;
42872
43498
  if (!opts.json && data.results.length > lastResultCount) {
42873
43499
  const newResults = data.results.slice(lastResultCount);
@@ -44388,10 +45014,11 @@ async function collectDiagnostics() {
44388
45014
  return [];
44389
45015
  }
44390
45016
  })();
44391
- const [claude, codex, grok, cursor, gpu] = await Promise.all([
45017
+ const [claude, codex, grok, agy, cursor, gpu] = await Promise.all([
44392
45018
  probeVersion("claude"),
44393
45019
  probeVersion("codex"),
44394
45020
  probeVersion("grok"),
45021
+ probeVersion("agy"),
44395
45022
  probeVersion("cursor"),
44396
45023
  detectGpu()
44397
45024
  ]);
@@ -44399,6 +45026,7 @@ async function collectDiagnostics() {
44399
45026
  if (claude) agents.claude_code = claude;
44400
45027
  if (codex) agents.codex = codex;
44401
45028
  if (grok) agents.grok = grok;
45029
+ if (agy) agents.agy = agy;
44402
45030
  if (cursor) agents.cursor = cursor;
44403
45031
  return {
44404
45032
  collected_at: (/* @__PURE__ */ new Date()).toISOString(),
@@ -46619,6 +47247,16 @@ var uninstallCommand = new Command("uninstall").description("Uninstall Gipity").
46619
47247
  }
46620
47248
  console.log(`${success(`Removed ${agentSkills.skills.length} Gipity skills from ~/.agents/skills.`)}`);
46621
47249
  }
47250
+ const agySkills = agySkillsState();
47251
+ if (agySkills.skills.length) {
47252
+ for (const name of agySkills.skills) {
47253
+ try {
47254
+ rmSync4(join25(AGY_SKILLS_DIR, name), { recursive: true, force: true });
47255
+ } catch {
47256
+ }
47257
+ }
47258
+ console.log(`${success(`Removed ${agySkills.skills.length} Gipity skills from ~/.gemini/config/skills.`)}`);
47259
+ }
46622
47260
  if (existsSync23(gipityDir)) {
46623
47261
  try {
46624
47262
  rmSync4(gipityDir, { recursive: true, force: true });
@@ -47238,8 +47876,8 @@ function configureHelp(cmd) {
47238
47876
  var program2 = new Command();
47239
47877
  program2.enablePositionalOptions();
47240
47878
  var startGroup = [statusCommand, initCommand, skillCommand, projectCommand];
47241
- var buildGroup = [addCommand, removeCommand, saveCommand, loadCommand, deployCommand, pageCommand, testCommand];
47242
- var backendGroup = [dbCommand, fnCommand, secretsCommand, logsCommand, jobCommand, workflowCommand];
47879
+ var buildGroup = [addCommand, removeCommand, brandCommand, saveCommand, loadCommand, deployCommand, pageCommand, testCommand];
47880
+ var backendGroup = [dbCommand, fnCommand, secretsCommand, keyCommand, logsCommand, jobCommand, workflowCommand];
47243
47881
  var servicesGroup = [serviceCommand, generateCommand, notifyCommand, paymentsCommand, realtimeCommand, recordsCommand, rbacCommand, auditCommand, domainCommand, tokenCommand];
47244
47882
  var filesGroup = [syncCommand, fileCommand, pushCommand, uploadCommand, storageCommand];
47245
47883
  var gipGroup = [chatCommand, memoryCommand, agentCommand, approvalCommand, gmailCommand];
@@ -47256,6 +47894,7 @@ var HELP_SECTIONS = [
47256
47894
  { title: "Connect & setup", cmds: connectGroup }
47257
47895
  ];
47258
47896
  var SKILL_DOCS = {
47897
+ brand: "web-app-basics",
47259
47898
  save: "app-import",
47260
47899
  load: "app-import",
47261
47900
  github: "app-import",
@@ -47268,6 +47907,7 @@ var SKILL_DOCS = {
47268
47907
  notify: "app-notify",
47269
47908
  payments: "app-payments",
47270
47909
  records: "app-records",
47910
+ key: "app-auth",
47271
47911
  realtime: "app-realtime",
47272
47912
  job: "jobs",
47273
47913
  sandbox: "sandbox-tools",