@homespunapps/cli 1.6.67 → 1.6.69

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.
@@ -49,6 +49,12 @@ export async function runApps(args) {
49
49
  return runShareLink(sub);
50
50
  case "delete":
51
51
  return runDelete(sub);
52
+ case "deleted":
53
+ return runDeleted(sub);
54
+ case "restore":
55
+ return runRestore(sub);
56
+ case "purge":
57
+ return runPurge(sub);
52
58
  case "wake":
53
59
  return runWake(sub);
54
60
  case "watch":
@@ -56,7 +62,7 @@ export async function runApps(args) {
56
62
  case "domain":
57
63
  return runDomain(sub);
58
64
  default:
59
- fail(`unknown verb '${verb}': homespun apps <list|show|audit|update|share-link|delete|wake|watch|domain>`, "invalid_args");
65
+ fail(`unknown verb '${verb}': homespun apps <list|show|audit|update|share-link|delete|deleted|restore|purge|wake|watch|domain>`, "invalid_args");
60
66
  }
61
67
  }
62
68
  // ---------------------------------------------------------------------------
@@ -66,8 +72,8 @@ async function runList(args) {
66
72
  assertKnownFlags(args, ...specFor("apps", "list"));
67
73
  const status = args.flags.get("status");
68
74
  if (status !== undefined &&
69
- !["active", "dormant", "archived", "suspended", "all"].includes(status)) {
70
- fail("--status must be active|dormant|archived|suspended|all", "invalid_args");
75
+ !["active", "dormant", "archived", "suspended", "deleted", "all"].includes(status)) {
76
+ fail("--status must be active|dormant|archived|suspended|deleted|all", "invalid_args");
71
77
  }
72
78
  const limitRaw = args.flags.get("limit");
73
79
  const limit = limitRaw !== undefined ? Number(limitRaw) : undefined;
@@ -225,13 +231,74 @@ async function runDelete(args) {
225
231
  if (!appArg)
226
232
  fail("usage: homespun apps delete <app> [--yes]", "invalid_args");
227
233
  if (!args.bools.has("yes")) {
228
- fail("'homespun apps delete' permanently removes the app and all its data it is destructive. Pass --yes to confirm.", "invalid_args");
234
+ fail("'homespun apps delete' takes the app offline immediately. Its data is kept and it can be brought back with 'homespun apps restore' until the retention window elapses ('homespun apps deleted' shows the deadline); 'homespun apps purge' destroys it for good. Pass --yes to confirm.", "invalid_args");
229
235
  }
230
236
  const client = makeClient(args);
231
237
  const id = await resolveAppId(client, appArg);
232
238
  try {
233
239
  await client.deleteApp(id);
234
- printJson({ deleted: true, app_id: id });
240
+ printJson({ deleted: true, app_id: id, restorable: true });
241
+ }
242
+ catch (e) {
243
+ failFromError(e);
244
+ }
245
+ }
246
+ // ---------------------------------------------------------------------------
247
+ // deleted / restore / purge
248
+ //
249
+ // Named after the row-level `data deleted|restore|purge` verbs that already do
250
+ // exactly this one level down, so the same word means the same thing whichever
251
+ // noun it is typed against.
252
+ // ---------------------------------------------------------------------------
253
+ async function runDeleted(args) {
254
+ assertKnownFlags(args, ...specFor("apps", "deleted"));
255
+ const limitRaw = args.flags.get("limit");
256
+ const limit = limitRaw !== undefined ? Number(limitRaw) : undefined;
257
+ if (limit !== undefined && !Number.isInteger(limit)) {
258
+ fail("--limit must be an integer", "invalid_args");
259
+ }
260
+ const client = makeClient(args);
261
+ try {
262
+ printJson(await client.listApps({
263
+ status: "deleted",
264
+ limit,
265
+ cursor: args.flags.get("cursor"),
266
+ }));
267
+ }
268
+ catch (e) {
269
+ failFromError(e);
270
+ }
271
+ }
272
+ async function runRestore(args) {
273
+ assertKnownFlags(args, ...specFor("apps", "restore"));
274
+ const appArg = args.positionals[0];
275
+ if (!appArg)
276
+ fail("usage: homespun apps restore <app>", "invalid_args");
277
+ const client = makeClient(args);
278
+ // includeDeleted, because the whole point is that this app is in trash: the
279
+ // default resolution path hides it and would fail with app_not_found.
280
+ const id = await resolveAppId(client, appArg, { includeDeleted: true });
281
+ try {
282
+ printJson(await client.restoreApp(id));
283
+ }
284
+ catch (e) {
285
+ failFromError(e);
286
+ }
287
+ }
288
+ async function runPurge(args) {
289
+ assertKnownFlags(args, ...specFor("apps", "purge"));
290
+ const appArg = args.positionals[0];
291
+ if (!appArg) {
292
+ fail("usage: homespun apps purge <app> [--yes]", "invalid_args");
293
+ }
294
+ if (!args.bools.has("yes")) {
295
+ fail("'homespun apps purge' destroys the app and all its data immediately and forever — there is no restore afterwards. Pass --yes to confirm.", "invalid_args");
296
+ }
297
+ const client = makeClient(args);
298
+ const id = await resolveAppId(client, appArg, { includeDeleted: true });
299
+ try {
300
+ await client.purgeApp(id);
301
+ printJson({ purged: true, app_id: id });
235
302
  }
236
303
  catch (e) {
237
304
  failFromError(e);
@@ -31,7 +31,7 @@ const APPS = {
31
31
  noun: "apps",
32
32
  tagline: "app lifecycle management",
33
33
  group: "app",
34
- rootSummary: "App lifecycle: list, show, audit (security review of your apps' collection permissions), update, delete, wake, domain (custom domains), watch (stream the app's change feed as JSON-lines).",
34
+ rootSummary: "App lifecycle: list, show, audit (security review of your apps' collection permissions), update, delete, deleted (what is in the trash), restore, purge (destroy a deleted app for good), wake, domain (custom domains), watch (stream the app's change feed as JSON-lines).",
35
35
  verbs: [
36
36
  {
37
37
  verb: "list",
@@ -39,7 +39,7 @@ const APPS = {
39
39
  flags: [
40
40
  {
41
41
  name: "status",
42
- value: "<active|dormant|archived|all>",
42
+ value: "<active|dormant|archived|deleted|all>",
43
43
  description: "Filter by lifecycle status",
44
44
  },
45
45
  { name: "limit", value: "<n>", description: "Page size" },
@@ -92,7 +92,26 @@ const APPS = {
92
92
  {
93
93
  verb: "delete",
94
94
  positionals: "<app>",
95
- summary: "Soft-deletes an app.",
95
+ summary: "Takes an app offline. Its data is kept and `apps restore` brings it back until the retention window elapses.",
96
+ bools: [{ name: "yes", description: "Skip the confirmation prompt" }],
97
+ },
98
+ {
99
+ verb: "deleted",
100
+ summary: "Lists your deleted apps, each with when it was deleted and when it is purged for good.",
101
+ flags: [
102
+ { name: "limit", value: "<n>", description: "Page size" },
103
+ { name: "cursor", value: "<cursor>", description: "Page cursor" },
104
+ ],
105
+ },
106
+ {
107
+ verb: "restore",
108
+ positionals: "<app>",
109
+ summary: "Brings a deleted app back, with all of its data. Fails if your account is at its app limit.",
110
+ },
111
+ {
112
+ verb: "purge",
113
+ positionals: "<app>",
114
+ summary: "Destroys a deleted app and all its data now, instead of waiting out the retention window. Irreversible.",
96
115
  bools: [{ name: "yes", description: "Skip the confirmation prompt" }],
97
116
  },
98
117
  {
@@ -29,10 +29,17 @@ function looksLikeId(value) {
29
29
  * `GET /v1/apps?slug=`. Values that don't look like a cuid skip straight to
30
30
  * the slug lookup. Fails with `app_not_found` if no app matches either way.
31
31
  */
32
- export async function resolveAppId(client, value) {
32
+ export async function resolveAppId(client, value, opts = {}) {
33
+ // Both lookups hide soft-deleted apps by default, which is right for every
34
+ // verb that operates on a live app and wrong for the three that operate on
35
+ // trash (`apps deleted|restore|purge`). Those pass includeDeleted, which
36
+ // swaps BOTH lookups for their trash-aware forms: `?include_deleted=true` on
37
+ // the detail read, and `?status=deleted` on the slug lookup, since
38
+ // `?status=all` means "every live status" and would never match.
39
+ const { includeDeleted = false } = opts;
33
40
  if (looksLikeId(value)) {
34
41
  try {
35
- await client.getApp(value);
42
+ await client.getApp(value, { includeDeleted });
36
43
  return value;
37
44
  }
38
45
  catch (e) {
@@ -42,10 +49,16 @@ export async function resolveAppId(client, value) {
42
49
  // Fall through: treat it as a slug instead.
43
50
  }
44
51
  }
45
- const page = await client.listApps({ status: "all", slug: value, limit: 1 });
52
+ const page = await client.listApps({
53
+ status: includeDeleted ? "deleted" : "all",
54
+ slug: value,
55
+ limit: 1,
56
+ });
46
57
  const match = page.items[0];
47
58
  if (!match) {
48
- fail(`no app found with slug '${value}'`, "app_not_found");
59
+ fail(includeDeleted
60
+ ? `no deleted app found with slug '${value}'`
61
+ : `no app found with slug '${value}'`, "app_not_found");
49
62
  }
50
63
  return match.id;
51
64
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homespunapps/cli",
3
- "version": "1.6.67",
3
+ "version": "1.6.69",
4
4
  "description": "Command-line client for the Homespun relay: deploy a real multi-user web app from your agent, then keep reading and writing its data.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,14 +36,14 @@
36
36
  "test:unit": "vitest run"
37
37
  },
38
38
  "dependencies": {
39
- "@homespunapps/core": "^1.6.67",
39
+ "@homespunapps/core": "^1.6.69",
40
40
  "qrcode-terminal": "^0.12.0"
41
41
  },
42
42
  "devDependencies": {
43
- "@types/node": "^26.1.2",
43
+ "@types/node": "^26.2.0",
44
44
  "@types/qrcode-terminal": "^0.12.2",
45
45
  "typescript": "^7.0.2",
46
- "vitest": "^4.1.10"
46
+ "vitest": "^4.1.11"
47
47
  },
48
48
  "repository": {
49
49
  "type": "git",