gipity 1.0.420 → 1.0.422

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.
@@ -80,11 +80,11 @@ export const deployCommand = new Command('deploy')
80
80
  const failedPhases = d.phases?.filter(p => p.status === 'failed') ?? [];
81
81
  if (failedPhases.length > 0) {
82
82
  // The database phase can fail on the account-wide database cap, whose
83
- // server message ("Maximum of N databases reached. Drop one first.")
84
- // names no command. The droppable databases live in OTHER projects, so
85
- // the default project-scoped `gipity db list` shows nothing — point the
86
- // caller straight at the account-wide list + drop path so they don't
87
- // dead-end (or reach for raw DB access) to free a slot.
83
+ // server message ("Maximum of N databases reached (you have N). Drop one
84
+ // first.") names no command. The droppable databases live in OTHER
85
+ // projects, so the default project-scoped `gipity db list` shows nothing
86
+ // — point the caller straight at the account-wide list + drop path so
87
+ // they don't dead-end (or reach for raw DB access) to free a slot.
88
88
  if (failedPhases.some(p => /databases? reached|database (cap|limit)/i.test(p.summary))) {
89
89
  console.log('');
90
90
  console.log(muted('Free a slot under the account database cap:'));
@@ -2,7 +2,8 @@ import { Command } from 'commander';
2
2
  import { readFileSync, existsSync, statSync } from 'fs';
3
3
  import { dirname, extname, relative } from 'path';
4
4
  import { post } from '../api.js';
5
- import { resolveProjectContext, getConfigPath } from '../config.js';
5
+ import { resolveProjectContext, getConfigPath, shouldIgnore } from '../config.js';
6
+ import { SCRATCH_IGNORE } from '../setup.js';
6
7
  import { sync } from '../sync.js';
7
8
  import { error as clrError, dim } from '../colors.js';
8
9
  import { run } from '../helpers/index.js';
@@ -225,12 +226,23 @@ GCC/Rust).
225
226
  const { config } = await resolveProjectContext();
226
227
  const timeout = parseInt(opts.timeout, 10);
227
228
  const cwd = resolveRelativeCwd();
229
+ // A scratch path is never synced, so it can never reach the VFS the sandbox
230
+ // mirrors from - `--input tmp/frame.png` would fail inside the container with
231
+ // a bare "no such file". Catch it here, where we can say why.
232
+ const scratchInputs = (opts.input ?? []).filter((p) => shouldIgnore(p.replace(/\\/g, '/').replace(/^\.\//, ''), SCRATCH_IGNORE));
233
+ if (scratchInputs.length) {
234
+ console.error(clrError(`Scratch paths are never mirrored into the sandbox: ${scratchInputs.join(', ')}`));
235
+ console.error(dim(` ${SCRATCH_IGNORE.join(', ')} are ignored by sync, so the sandbox never sees them.`));
236
+ console.error(dim(' Stage inputs at a real project path (src/, docs/, assets/) and delete them afterward.'));
237
+ process.exit(1);
238
+ }
228
239
  // Push local working-tree changes up before executing. The sandbox mirrors
229
240
  // the *server* (VFS), not the local cwd, so any input staged outside Claude's
230
241
  // Write/Edit auto-push hook - a Bash `cp`/`ffmpeg`/redirect, or any external
231
242
  // process - would otherwise be invisible to the run and the first invocation
232
243
  // would silently miss its inputs. Syncing first makes the auto-mirror reflect
233
- // local state regardless of how files got there ("no manual copy needed").
244
+ // local state however files got there - with the one exception of the scratch
245
+ // namespaces above, which sync ignores and so the mirror never carries.
234
246
  // Bidirectional + CAS, so it's a cheap manifest check when nothing changed.
235
247
  // Symmetric with the post-run pull below. Skip in one-off mode (no project).
236
248
  if (getConfigPath()) {
@@ -38,12 +38,25 @@ function printUsage(d) {
38
38
  row('Dedup saved', formatBytes(s.dedupSavedBytes), `${s.dedupedObjects.toLocaleString()} shared`);
39
39
  row('Billed', formatBytes(s.physicalBytes), 'versions kept, dedup applied');
40
40
  if (d.projects.length > 0) {
41
+ // The server returns only the biggest projects. Say so, and account for the
42
+ // rest - a list that looks exhaustive but isn't sends people hunting for
43
+ // space in the wrong place.
44
+ const totals = d.projectTotals;
45
+ const hidden = totals.count - d.projects.length;
41
46
  console.log('');
42
- console.log(`${bold('By project')} ${muted('(live files only)')}`);
47
+ const scope = hidden > 0
48
+ ? `(top ${d.projects.length} of ${totals.count.toLocaleString()}, live files only)`
49
+ : '(live files only)';
50
+ console.log(`${bold('By project')} ${muted(scope)}`);
43
51
  for (const p of d.projects) {
44
52
  const name = p.projectName ?? '(no project)';
45
53
  row(name.length > 16 ? `${name.slice(0, 15)}…` : name, formatBytes(p.liveBytes), `${p.liveFiles.toLocaleString()} files`);
46
54
  }
55
+ if (hidden > 0) {
56
+ const shownBytes = d.projects.reduce((n, p) => n + p.liveBytes, 0);
57
+ const shownFiles = d.projects.reduce((n, p) => n + p.liveFiles, 0);
58
+ row(`…and ${hidden.toLocaleString()} more`, formatBytes(totals.liveBytes - shownBytes), `${(totals.liveFiles - shownFiles).toLocaleString()} files`);
59
+ }
47
60
  }
48
61
  const r = d.versionRetention;
49
62
  console.log('');
package/dist/knowledge.js CHANGED
@@ -154,7 +154,7 @@ To keep local-only material (research clones, scratch data, vendored references)
154
154
  Deploy is opt-in, not opt-out: the \`files\` phase uploads **only** what's under \`src/\` (plus \`functions/\` and \`migrations/\` as backend, not CDN files). Anything else at the project root is kept but never deployed. Put each kind of file in the right bucket so scratch and reference material can't bloat a deploy:
155
155
 
156
156
  - **\`src/\`** - the app itself. Synced **and** deployed to the CDN. Only app code, assets, and pages belong here.
157
- - **\`tmp/\`** - ephemeral scratch: file conversions, intermediate outputs, design staging. **Already ignored** (never synced, never deployed) - the one place to do throwaway work. Use this single root. (\`*_tmp/\` dirs and \`.gipityscratch/\` are auto-ignored too, as a safety net, so legacy scattered scratch like \`_vsd_tmp/\` can't leak - but write new scratch to \`tmp/\`, not scattered dirs.)
157
+ - **\`tmp/\`** - ephemeral scratch: file conversions, intermediate outputs, design staging. **Already ignored** (never synced, never deployed) - the one place to do throwaway work. Use this single root. (\`*_tmp/\` dirs and \`.gipityscratch/\` are auto-ignored too, as a safety net, so legacy scattered scratch like \`_vsd_tmp/\` can't leak - but write new scratch to \`tmp/\`, not scattered dirs.) Because it never syncs, \`tmp/\` is also never mirrored into the sandbox: **don't stage \`gipity sandbox run\` inputs here** - the sandbox won't see them. Stage inputs under \`src/\`/\`docs/\` and delete them after.
158
158
  - **\`docs/\`** - reference material you want to keep: UI/architecture diagrams, design decks, notes, ADRs. Synced and versioned on the server (backed up, rollback-able) but **never deployed**, because it's outside \`src/\`. This is the home for "keep forever, don't ship" artifacts.
159
159
  - **\`tests/\`** - \`*.test.js\` suites. Synced, run by \`gipity test\`, never deployed. \`gipity test list [path]\` lists the test files (and what a filter selects) without running them.
160
160
 
package/dist/sync.js CHANGED
@@ -1179,6 +1179,9 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
1179
1179
  // turns "files we failed to fetch" into "delete those files." Skip ALL deletes
1180
1180
  // this run and let a clean sync replan them once the pull succeeds.
1181
1181
  let deletesSkippedIncomplete = 0;
1182
+ // Directories that lost a file to a delete-local this run. Only these are
1183
+ // candidates for empty-dir cleanup — see cleanupEmptyDirs.
1184
+ const dirsTouchedByDelete = new Set();
1182
1185
  for (const a of plannedToApply) {
1183
1186
  if (downloadIncomplete && (a.kind === 'delete-local' || a.kind === 'delete-remote')) {
1184
1187
  deletesSkippedIncomplete++;
@@ -1199,11 +1202,13 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
1199
1202
  }
1200
1203
  try {
1201
1204
  unlinkSync(full);
1205
+ dirsTouchedByDelete.add(dirname(full));
1202
1206
  delete baseline.files[a.path];
1203
1207
  applied++;
1204
1208
  }
1205
1209
  catch (err) {
1206
1210
  if (err.code === 'ENOENT') {
1211
+ dirsTouchedByDelete.add(dirname(full));
1207
1212
  delete baseline.files[a.path];
1208
1213
  applied++;
1209
1214
  }
@@ -1273,8 +1278,8 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
1273
1278
  errors.push(`Skipped ${deletesSkippedIncomplete} deletion${deletesSkippedIncomplete === 1 ? '' : 's'} because the download was incomplete - ` +
1274
1279
  `nothing was deleted. Re-run \`gipity sync\` once the pull finishes to apply any real deletions.`);
1275
1280
  }
1276
- // Clean up empty local directories after delete-local actions.
1277
- cleanupEmptyDirs(root, config.ignore);
1281
+ // Clean up local directories emptied by this run's delete-local actions.
1282
+ cleanupEmptyDirs(root, dirsTouchedByDelete);
1278
1283
  // Adopt agreed content-match state into the baseline, and prune stale entries.
1279
1284
  // `plan()` is pure, so it emits these as lists and the mutation happens here.
1280
1285
  // Adopting (FIX M1) makes sync convergent - a locally+remotely-identical file
@@ -1296,43 +1301,51 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
1296
1301
  deferredDeletes: skippedByGuard,
1297
1302
  };
1298
1303
  }
1299
- function cleanupEmptyDirs(root, ignorePatterns) {
1300
- function walk(dir) {
1301
- let entries;
1304
+ /**
1305
+ * Remove directories that THIS run's delete-local actions left empty.
1306
+ *
1307
+ * Scoped to `emptiedDirs` on purpose. The previous version walked the whole
1308
+ * project tree and removed every empty directory it found, which also deleted
1309
+ * ones the user had just created: `mkdir -p src/images/monsters` followed a few
1310
+ * tool calls later by a write into it failed with ENOENT, because a full sync
1311
+ * had pruned the directory in between. The VFS stores files only, so a freshly
1312
+ * created empty directory has no remote counterpart and looked indistinguishable
1313
+ * from "deleted on the server."
1314
+ *
1315
+ * Pruning only where sync actually removed a file preserves the original intent
1316
+ * — don't leave an empty husk behind after a remote delete — while leaving every
1317
+ * other directory alone. An unrelated empty directory is now simply never
1318
+ * sync's business.
1319
+ *
1320
+ * Prunes upward, since removing `a/b/c` can leave `a/b` empty too. A directory
1321
+ * holding only sync-ignored entries is not empty on disk, so it survives — the
1322
+ * same outcome the old ignore-aware walk produced.
1323
+ */
1324
+ function cleanupEmptyDirs(root, emptiedDirs) {
1325
+ const isEmpty = (dir) => {
1302
1326
  try {
1303
- entries = readdirSync(dir, { withFileTypes: true });
1327
+ return readdirSync(dir).length === 0;
1304
1328
  }
1305
1329
  catch {
1306
1330
  return false;
1307
1331
  }
1308
- let kept = 0;
1309
- for (const entry of entries) {
1310
- const full = join(dir, entry.name);
1311
- const rel = relative(root, full).replace(/\\/g, '/');
1312
- if (shouldIgnore(rel, ignorePatterns)) {
1313
- kept++;
1314
- continue;
1315
- }
1316
- if (entry.isDirectory()) {
1317
- if (!walk(full))
1318
- kept++;
1319
- }
1320
- else {
1321
- kept++;
1322
- }
1323
- }
1324
- if (kept === 0 && dir !== root) {
1332
+ };
1333
+ // `emptiedDirs` holds dirname()s of resolveInRoot() results, which are
1334
+ // lexically resolved — resolve the root the same way, or the containment
1335
+ // guard below never matches and nothing is ever pruned.
1336
+ const rootResolved = resolve(root);
1337
+ for (const start of emptiedDirs) {
1338
+ let dir = resolve(start);
1339
+ while (dir !== rootResolved && dir.startsWith(rootResolved + sep) && isEmpty(dir)) {
1325
1340
  try {
1326
1341
  rmdirSync(dir);
1327
- return true;
1328
1342
  }
1329
1343
  catch {
1330
- return false;
1344
+ break;
1331
1345
  }
1346
+ dir = dirname(dir);
1332
1347
  }
1333
- return false;
1334
1348
  }
1335
- walk(root);
1336
1349
  }
1337
1350
  // ─── Single-file push (used by `gipity push <file>`) ───────────
1338
1351
  export async function pushFile(filePath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gipity",
3
- "version": "1.0.420",
3
+ "version": "1.0.422",
4
4
  "description": "The full-stack platform tuned for AI agents. Database, storage, auth, functions, deploy, and drop-in kits - all agent-tuned. Pair with Claude Code or use standalone.",
5
5
  "bin": {
6
6
  "gipity": "dist/updater/shim.js",