@mondaydotcomorg/z2h-cli 0.13.1 → 0.13.2

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.
@@ -0,0 +1,6 @@
1
+ export interface DeleteOptions {
2
+ yes?: boolean;
3
+ json?: boolean;
4
+ }
5
+ export declare function deleteCommand(opts?: DeleteOptions): Promise<void>;
6
+ //# sourceMappingURL=delete.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"delete.d.ts","sourceRoot":"","sources":["../../src/commands/delete.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,aAAa;IAC5B,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAkBD,wBAAsB,aAAa,CAAC,IAAI,GAAE,aAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAmC3E"}
@@ -0,0 +1,53 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
+
3
+ const node_readline = require('node:readline');
4
+ const util_logger = require('../util/logger.js');
5
+ const util_appName = require('../util/app-name.js');
6
+ const constants = require('../constants.js');
7
+ const util_auth_authStore = require('../util/auth/auth-store.js');
8
+
9
+ // Type-the-app-name confirmation. The caller must retype the de-scoped app
10
+ // name (what they see in the URL) to guard against accidental deletes.
11
+ async function confirmByName(appSlug) {
12
+ const rl = node_readline.createInterface({ input: process.stdin, output: process.stderr });
13
+ const answer = await new Promise(resolve => {
14
+ rl.question(`This permanently deletes "${appSlug}" and takes it offline. This cannot be undone.\n` +
15
+ `Type the app name to confirm: `, resolve);
16
+ });
17
+ rl.close();
18
+ return answer.trim() === appSlug;
19
+ }
20
+ // Should be run from the consumer app directory — uses process.cwd() to locate it.
21
+ async function deleteCommand(opts = {}) {
22
+ if (opts.json) {
23
+ util_logger.setOutputMode('json');
24
+ }
25
+ const cwdName = util_appName.getCwdAppName();
26
+ if (!cwdName) {
27
+ throw new Error('Run this from inside a consumer app directory (the one with your package.json).');
28
+ }
29
+ const appSlug = util_appName.cleanAppName(cwdName); // user-facing name (matches the bigbrain.me URL)
30
+ const appName = util_appName.mfAppName(cwdName); // canonical mf- bundle name the backend keys on
31
+ // Confirmation. --json is always non-interactive, so it requires --yes.
32
+ if (!opts.yes) {
33
+ if (opts.json || !process.stdin.isTTY) {
34
+ throw new Error('Refusing to delete without confirmation — pass --yes to delete non-interactively.');
35
+ }
36
+ if (!(await confirmByName(appSlug))) {
37
+ throw new Error(`Name did not match "${appSlug}" — aborted, nothing was deleted.`);
38
+ }
39
+ }
40
+ const { token } = await util_auth_authStore.readZthAuth();
41
+ util_logger.info(`deleting "${appName}"`);
42
+ const res = await fetch(`${constants.brokerBaseUrl()}/z2h-apps/${encodeURIComponent(appName)}`, {
43
+ method: 'DELETE',
44
+ headers: { Authorization: `Bearer ${token}`, ...constants.routingHeaders() },
45
+ });
46
+ if (!res.ok) {
47
+ const text = await res.text();
48
+ throw new Error(`Delete failed (${res.status}): ${text.slice(0, 1024)}`);
49
+ }
50
+ util_logger.output(`[z2h-cli] "${appName}" deleted ✅ — it is now offline.`, { ok: true, appName, deleted: true });
51
+ }
52
+
53
+ exports.deleteCommand = deleteCommand;
@@ -0,0 +1,6 @@
1
+ export interface DeleteOptions {
2
+ yes?: boolean;
3
+ json?: boolean;
4
+ }
5
+ export declare function deleteCommand(opts?: DeleteOptions): Promise<void>;
6
+ //# sourceMappingURL=delete.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"delete.d.ts","sourceRoot":"","sources":["../../../src/commands/delete.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,aAAa;IAC5B,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAkBD,wBAAsB,aAAa,CAAC,IAAI,GAAE,aAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAmC3E"}
@@ -0,0 +1,51 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { setOutputMode, info, output } from '../util/logger.mjs';
3
+ import { getCwdAppName, cleanAppName, mfAppName } from '../util/app-name.mjs';
4
+ import { brokerBaseUrl, routingHeaders } from '../constants.mjs';
5
+ import { readZthAuth } from '../util/auth/auth-store.mjs';
6
+
7
+ // Type-the-app-name confirmation. The caller must retype the de-scoped app
8
+ // name (what they see in the URL) to guard against accidental deletes.
9
+ async function confirmByName(appSlug) {
10
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
11
+ const answer = await new Promise(resolve => {
12
+ rl.question(`This permanently deletes "${appSlug}" and takes it offline. This cannot be undone.\n` +
13
+ `Type the app name to confirm: `, resolve);
14
+ });
15
+ rl.close();
16
+ return answer.trim() === appSlug;
17
+ }
18
+ // Should be run from the consumer app directory — uses process.cwd() to locate it.
19
+ async function deleteCommand(opts = {}) {
20
+ if (opts.json) {
21
+ setOutputMode('json');
22
+ }
23
+ const cwdName = getCwdAppName();
24
+ if (!cwdName) {
25
+ throw new Error('Run this from inside a consumer app directory (the one with your package.json).');
26
+ }
27
+ const appSlug = cleanAppName(cwdName); // user-facing name (matches the bigbrain.me URL)
28
+ const appName = mfAppName(cwdName); // canonical mf- bundle name the backend keys on
29
+ // Confirmation. --json is always non-interactive, so it requires --yes.
30
+ if (!opts.yes) {
31
+ if (opts.json || !process.stdin.isTTY) {
32
+ throw new Error('Refusing to delete without confirmation — pass --yes to delete non-interactively.');
33
+ }
34
+ if (!(await confirmByName(appSlug))) {
35
+ throw new Error(`Name did not match "${appSlug}" — aborted, nothing was deleted.`);
36
+ }
37
+ }
38
+ const { token } = await readZthAuth();
39
+ info(`deleting "${appName}"`);
40
+ const res = await fetch(`${brokerBaseUrl()}/z2h-apps/${encodeURIComponent(appName)}`, {
41
+ method: 'DELETE',
42
+ headers: { Authorization: `Bearer ${token}`, ...routingHeaders() },
43
+ });
44
+ if (!res.ok) {
45
+ const text = await res.text();
46
+ throw new Error(`Delete failed (${res.status}): ${text.slice(0, 1024)}`);
47
+ }
48
+ output(`[z2h-cli] "${appName}" deleted ✅ — it is now offline.`, { ok: true, appName, deleted: true });
49
+ }
50
+
51
+ export { deleteCommand };
@@ -8,6 +8,7 @@ import { devCommand } from './commands/dev.mjs';
8
8
  import { buildCommand } from './commands/build.mjs';
9
9
  import { cleanCommand } from './commands/clean.mjs';
10
10
  import { deployCommand } from './commands/deploy.mjs';
11
+ import { deleteCommand } from './commands/delete.mjs';
11
12
  import { grantCommand, revokeCommand, transferOwnerCommand } from './commands/grant.mjs';
12
13
  import { createWorkspaceCommand } from './commands/create-workspace.mjs';
13
14
  import { createCommand } from './commands/create.mjs';
@@ -228,6 +229,21 @@ async function main() {
228
229
  await fail(`transfer-owner failed: ${err.message}`, { command: 'transfer-owner' }, 1);
229
230
  }
230
231
  });
232
+ program
233
+ .command('delete')
234
+ .description('Permanently delete the current app and take it offline (owners/admins only)')
235
+ .option('--yes', 'skip the confirmation prompt (required for --json / non-interactive)')
236
+ .option('--json', 'emit structured JSON on stdout')
237
+ .action(async (opts) => {
238
+ try {
239
+ await trackDuration('z2h_app_delete_success', () => deleteCommand(opts), {
240
+ app_name: getCwdAppName(),
241
+ });
242
+ }
243
+ catch (err) {
244
+ await fail(`delete failed: ${err.message}`, { command: 'delete' }, 1);
245
+ }
246
+ });
231
247
  program
232
248
  .command('migrate')
233
249
  .description('Run pending workspace migrations')
@@ -1 +1 @@
1
- {"version":3,"file":"repo.d.ts","sourceRoot":"","sources":["../../../../src/util/git/repo.ts"],"names":[],"mappings":"AAkBA,qBAAa,gBAAiB,SAAQ,KAAK;gBAC7B,OAAO,EAAE,MAAM;CAI5B;AAED,MAAM,WAAW,YAAY;IAC3B,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,gBAAgB,EAAE,MAAM,EAAE,CAAC;CAC5B;AAKD,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED,wBAAsB,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAOzE;AAED,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAI3E;AAED;;;;GAIG;AACH,wBAAsB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAK/D;AAED,wBAAsB,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAG5G;AAED,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAclE;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAe7F;AAED;;;;;;GAMG;AACH,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAqB3F;AAED,wBAAsB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAO7D;AAED,wBAAsB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAG5D"}
1
+ {"version":3,"file":"repo.d.ts","sourceRoot":"","sources":["../../../../src/util/git/repo.ts"],"names":[],"mappings":"AAkBA,qBAAa,gBAAiB,SAAQ,KAAK;gBAC7B,OAAO,EAAE,MAAM;CAI5B;AAED,MAAM,WAAW,YAAY;IAC3B,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,gBAAgB,EAAE,MAAM,EAAE,CAAC;CAC5B;AAKD,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED,wBAAsB,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAOzE;AAED,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAI3E;AAED;;;;GAIG;AACH,wBAAsB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAK/D;AAED,wBAAsB,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAG5G;AAED,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAclE;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CA2B7F;AAED;;;;;;GAMG;AACH,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAqB3F;AAED,wBAAsB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAO7D;AAED,wBAAsB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAG5D"}
@@ -69,6 +69,16 @@ async function safeBackupIfDirty(dir) {
69
69
  }
70
70
  async function pullAndResolve(dir, env) {
71
71
  const gitEnv = env ? { ...process.env, ...env } : undefined;
72
+ // If the remote has no master branch there's nothing to pull — treat it as a
73
+ // first deploy and let the subsequent push create it. This covers a brand-new
74
+ // remote as well as one whose objects were removed (e.g. the app was deleted,
75
+ // wiping its S3 git prefix), which would otherwise surface as a bogus conflict.
76
+ const hasRemoteMaster = await execGit(['ls-remote', '--heads', 'origin', 'master'], dir, gitEnv)
77
+ .then(r => Boolean(r.stdout.trim()))
78
+ .catch(() => false);
79
+ if (!hasRemoteMaster) {
80
+ return;
81
+ }
72
82
  try {
73
83
  await execGit(['pull', '--ff-only'], dir, gitEnv);
74
84
  }
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ const commands_dev = require('./commands/dev.js');
8
8
  const commands_build = require('./commands/build.js');
9
9
  const commands_clean = require('./commands/clean.js');
10
10
  const commands_deploy = require('./commands/deploy.js');
11
+ const commands_delete = require('./commands/delete.js');
11
12
  const commands_grant = require('./commands/grant.js');
12
13
  const commands_createWorkspace = require('./commands/create-workspace.js');
13
14
  const commands_create = require('./commands/create.js');
@@ -234,6 +235,21 @@ async function main() {
234
235
  await util_logger.fail(`transfer-owner failed: ${err.message}`, { command: 'transfer-owner' }, 1);
235
236
  }
236
237
  });
238
+ program
239
+ .command('delete')
240
+ .description('Permanently delete the current app and take it offline (owners/admins only)')
241
+ .option('--yes', 'skip the confirmation prompt (required for --json / non-interactive)')
242
+ .option('--json', 'emit structured JSON on stdout')
243
+ .action(async (opts) => {
244
+ try {
245
+ await util_tracker.trackDuration('z2h_app_delete_success', () => commands_delete.deleteCommand(opts), {
246
+ app_name: util_appName.getCwdAppName(),
247
+ });
248
+ }
249
+ catch (err) {
250
+ await util_logger.fail(`delete failed: ${err.message}`, { command: 'delete' }, 1);
251
+ }
252
+ });
237
253
  program
238
254
  .command('migrate')
239
255
  .description('Run pending workspace migrations')
@@ -1 +1 @@
1
- {"version":3,"file":"repo.d.ts","sourceRoot":"","sources":["../../../src/util/git/repo.ts"],"names":[],"mappings":"AAkBA,qBAAa,gBAAiB,SAAQ,KAAK;gBAC7B,OAAO,EAAE,MAAM;CAI5B;AAED,MAAM,WAAW,YAAY;IAC3B,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,gBAAgB,EAAE,MAAM,EAAE,CAAC;CAC5B;AAKD,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED,wBAAsB,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAOzE;AAED,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAI3E;AAED;;;;GAIG;AACH,wBAAsB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAK/D;AAED,wBAAsB,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAG5G;AAED,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAclE;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAe7F;AAED;;;;;;GAMG;AACH,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAqB3F;AAED,wBAAsB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAO7D;AAED,wBAAsB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAG5D"}
1
+ {"version":3,"file":"repo.d.ts","sourceRoot":"","sources":["../../../src/util/git/repo.ts"],"names":[],"mappings":"AAkBA,qBAAa,gBAAiB,SAAQ,KAAK;gBAC7B,OAAO,EAAE,MAAM;CAI5B;AAED,MAAM,WAAW,YAAY;IAC3B,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,gBAAgB,EAAE,MAAM,EAAE,CAAC;CAC5B;AAKD,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED,wBAAsB,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAOzE;AAED,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAI3E;AAED;;;;GAIG;AACH,wBAAsB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAK/D;AAED,wBAAsB,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAG5G;AAED,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAclE;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CA2B7F;AAED;;;;;;GAMG;AACH,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAqB3F;AAED,wBAAsB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAO7D;AAED,wBAAsB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAG5D"}
@@ -76,6 +76,16 @@ async function safeBackupIfDirty(dir) {
76
76
  }
77
77
  async function pullAndResolve(dir, env) {
78
78
  const gitEnv = env ? { ...process.env, ...env } : undefined;
79
+ // If the remote has no master branch there's nothing to pull — treat it as a
80
+ // first deploy and let the subsequent push create it. This covers a brand-new
81
+ // remote as well as one whose objects were removed (e.g. the app was deleted,
82
+ // wiping its S3 git prefix), which would otherwise surface as a bogus conflict.
83
+ const hasRemoteMaster = await execGit(['ls-remote', '--heads', 'origin', 'master'], dir, gitEnv)
84
+ .then(r => Boolean(r.stdout.trim()))
85
+ .catch(() => false);
86
+ if (!hasRemoteMaster) {
87
+ return;
88
+ }
79
89
  try {
80
90
  await execGit(['pull', '--ff-only'], dir, gitEnv);
81
91
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mondaydotcomorg/z2h-cli",
3
- "version": "0.13.1",
3
+ "version": "0.13.2",
4
4
  "bin": "./bin/z2h-cli.js",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -0,0 +1,63 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { output, info, setOutputMode } from '../util/logger';
3
+ import { getCwdAppName, cleanAppName, mfAppName } from '../util/app-name';
4
+ import { brokerBaseUrl, routingHeaders } from '../constants';
5
+ import { readZthAuth } from '../util/auth/auth-store';
6
+
7
+ export interface DeleteOptions {
8
+ yes?: boolean;
9
+ json?: boolean;
10
+ }
11
+
12
+ // Type-the-app-name confirmation. The caller must retype the de-scoped app
13
+ // name (what they see in the URL) to guard against accidental deletes.
14
+ async function confirmByName(appSlug: string): Promise<boolean> {
15
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
16
+ const answer = await new Promise<string>(resolve => {
17
+ rl.question(
18
+ `This permanently deletes "${appSlug}" and takes it offline. This cannot be undone.\n` +
19
+ `Type the app name to confirm: `,
20
+ resolve
21
+ );
22
+ });
23
+ rl.close();
24
+ return answer.trim() === appSlug;
25
+ }
26
+
27
+ // Should be run from the consumer app directory — uses process.cwd() to locate it.
28
+ export async function deleteCommand(opts: DeleteOptions = {}): Promise<void> {
29
+ if (opts.json) {
30
+ setOutputMode('json');
31
+ }
32
+
33
+ const cwdName = getCwdAppName();
34
+ if (!cwdName) {
35
+ throw new Error('Run this from inside a consumer app directory (the one with your package.json).');
36
+ }
37
+ const appSlug = cleanAppName(cwdName); // user-facing name (matches the bigbrain.me URL)
38
+ const appName = mfAppName(cwdName); // canonical mf- bundle name the backend keys on
39
+
40
+ // Confirmation. --json is always non-interactive, so it requires --yes.
41
+ if (!opts.yes) {
42
+ if (opts.json || !process.stdin.isTTY) {
43
+ throw new Error('Refusing to delete without confirmation — pass --yes to delete non-interactively.');
44
+ }
45
+ if (!(await confirmByName(appSlug))) {
46
+ throw new Error(`Name did not match "${appSlug}" — aborted, nothing was deleted.`);
47
+ }
48
+ }
49
+
50
+ const { token } = await readZthAuth();
51
+
52
+ info(`deleting "${appName}"`);
53
+ const res = await fetch(`${brokerBaseUrl()}/z2h-apps/${encodeURIComponent(appName)}`, {
54
+ method: 'DELETE',
55
+ headers: { Authorization: `Bearer ${token}`, ...routingHeaders() },
56
+ });
57
+ if (!res.ok) {
58
+ const text = await res.text();
59
+ throw new Error(`Delete failed (${res.status}): ${text.slice(0, 1024)}`);
60
+ }
61
+
62
+ output(`[z2h-cli] "${appName}" deleted ✅ — it is now offline.`, { ok: true, appName, deleted: true });
63
+ }
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ import { devCommand } from './commands/dev';
8
8
  import { buildCommand } from './commands/build';
9
9
  import { cleanCommand } from './commands/clean';
10
10
  import { deployCommand } from './commands/deploy';
11
+ import { deleteCommand } from './commands/delete';
11
12
  import { grantCommand, revokeCommand, transferOwnerCommand } from './commands/grant';
12
13
  import { createWorkspaceCommand } from './commands/create-workspace';
13
14
  import { createCommand, CreateOptions } from './commands/create';
@@ -254,6 +255,21 @@ async function main(): Promise<void> {
254
255
  }
255
256
  });
256
257
 
258
+ program
259
+ .command('delete')
260
+ .description('Permanently delete the current app and take it offline (owners/admins only)')
261
+ .option('--yes', 'skip the confirmation prompt (required for --json / non-interactive)')
262
+ .option('--json', 'emit structured JSON on stdout')
263
+ .action(async (opts: { yes?: boolean; json?: boolean }) => {
264
+ try {
265
+ await trackDuration('z2h_app_delete_success', () => deleteCommand(opts), {
266
+ app_name: getCwdAppName(),
267
+ });
268
+ } catch (err) {
269
+ await fail(`delete failed: ${(err as Error).message}`, { command: 'delete' }, 1);
270
+ }
271
+ });
272
+
257
273
  program
258
274
  .command('migrate')
259
275
  .description('Run pending workspace migrations')
@@ -179,13 +179,35 @@ describe('safeBackupIfDirty', () => {
179
179
 
180
180
  describe('pullAndResolve', () => {
181
181
  it('resolves when pull fast-forwards cleanly', async () => {
182
- mockExeca.mockResolvedValueOnce(execaOk()); // git pull --ff-only
182
+ mockExeca
183
+ .mockResolvedValueOnce(execaOk('deadbeef\trefs/heads/master')) // ls-remote --heads origin master
184
+ .mockResolvedValueOnce(execaOk()); // git pull --ff-only
183
185
 
184
186
  await expect(pullAndResolve(DIR)).resolves.toBeUndefined();
185
187
  });
186
188
 
189
+ it('skips the pull (first-deploy) when the remote has no master ref', async () => {
190
+ mockExeca.mockResolvedValueOnce(execaOk('')); // ls-remote --heads origin master → empty (gone/new remote)
191
+
192
+ await expect(pullAndResolve(DIR)).resolves.toBeUndefined();
193
+
194
+ const calls = mockExeca.mock.calls.map(c => c.slice(0, 2));
195
+ expect(calls).toContainEqual(['git', ['ls-remote', '--heads', 'origin', 'master']]);
196
+ expect(calls).not.toContainEqual(['git', ['pull', '--ff-only']]);
197
+ });
198
+
199
+ it('treats an unreachable ls-remote as no remote and skips the pull', async () => {
200
+ mockExeca.mockRejectedValueOnce(new Error('fatal: could not read from remote')); // ls-remote fails
201
+
202
+ await expect(pullAndResolve(DIR)).resolves.toBeUndefined();
203
+
204
+ const calls = mockExeca.mock.calls.map(c => c.slice(0, 2));
205
+ expect(calls).not.toContainEqual(['git', ['pull', '--ff-only']]);
206
+ });
207
+
187
208
  it('throws GitConflictError with structured JSON on non-fast-forward', async () => {
188
209
  mockExeca
210
+ .mockResolvedValueOnce(execaOk('deadbeef\trefs/heads/master')) // ls-remote --heads origin master
189
211
  .mockRejectedValueOnce(new Error('fatal: Not possible to fast-forward')) // git pull
190
212
  .mockResolvedValueOnce(execaOk('')) // safeBackupIfDirty: git status --porcelain (clean)
191
213
  .mockResolvedValueOnce(execaOk()) // gatherConflictInfo: git fetch origin
@@ -86,6 +86,18 @@ export async function safeBackupIfDirty(dir: string): Promise<void> {
86
86
 
87
87
  export async function pullAndResolve(dir: string, env?: Record<string, string>): Promise<void> {
88
88
  const gitEnv = env ? { ...process.env, ...env } : undefined;
89
+
90
+ // If the remote has no master branch there's nothing to pull — treat it as a
91
+ // first deploy and let the subsequent push create it. This covers a brand-new
92
+ // remote as well as one whose objects were removed (e.g. the app was deleted,
93
+ // wiping its S3 git prefix), which would otherwise surface as a bogus conflict.
94
+ const hasRemoteMaster = await execGit(['ls-remote', '--heads', 'origin', 'master'], dir, gitEnv)
95
+ .then(r => Boolean(r.stdout.trim()))
96
+ .catch(() => false);
97
+ if (!hasRemoteMaster) {
98
+ return;
99
+ }
100
+
89
101
  try {
90
102
  await execGit(['pull', '--ff-only'], dir, gitEnv);
91
103
  } catch (err) {