@mondaydotcomorg/z2h-cli 0.13.0 → 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,63 @@
1
+ import { brokerBaseUrl, routingHeaders } from '../constants.mjs';
2
+ import { readZthAuth } from './auth/auth-store.mjs';
3
+
4
+ /** The tag dimensions, in prompt order. */
5
+ const TAG_TYPES = ['app_type', 'team'];
6
+ /** Human labels for each dimension (CLI prompts / output). */
7
+ const TAG_TYPE_LABELS = {
8
+ app_type: 'App type',
9
+ team: 'Team',
10
+ };
11
+ /**
12
+ * Authenticated request to the bigbrain-zth tag endpoints. Mirrors the Bearer
13
+ * pattern used by the STS broker / lock-release calls, with network-error
14
+ * wrapping, auth-failure guidance, and response-body inclusion on failure.
15
+ */
16
+ async function tagFetch(method, path, body) {
17
+ const { token } = await readZthAuth();
18
+ let res;
19
+ try {
20
+ res = await fetch(`${brokerBaseUrl()}${path}`, {
21
+ method,
22
+ headers: {
23
+ Authorization: `Bearer ${token}`,
24
+ Accept: 'application/json',
25
+ ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
26
+ ...routingHeaders(),
27
+ },
28
+ body: body === undefined ? undefined : JSON.stringify(body),
29
+ });
30
+ }
31
+ catch (err) {
32
+ const message = err instanceof Error ? err.message : String(err);
33
+ throw new Error(`Z2H tag request failed: ${message}`);
34
+ }
35
+ if (res.status === 401 || res.status === 403) {
36
+ throw new Error(`Z2H tag request rejected by the broker (${res.status}). Refresh your token by running 'z2h-cli generate-z2h-token'.`);
37
+ }
38
+ if (!res.ok) {
39
+ const text = await res.text();
40
+ throw new Error(`Z2H tag request returned ${res.status}: ${text.slice(0, 1024)}`);
41
+ }
42
+ return res;
43
+ }
44
+ /** The full closed tag catalog (app types + teams), with usage counts. */
45
+ async function fetchTagCatalog() {
46
+ const res = await tagFetch('GET', '/z2h-cli/tags');
47
+ const data = (await res.json());
48
+ return data.tags ?? [];
49
+ }
50
+ /** Tags currently assigned to an app (bare slug). */
51
+ async function fetchAppTags(appSlug) {
52
+ const res = await tagFetch('GET', `/z2h-cli/tags/${encodeURIComponent(appSlug)}`);
53
+ const data = (await res.json());
54
+ return data.tags ?? [];
55
+ }
56
+ /** Full-replace an app's tag set; returns the resulting tags. */
57
+ async function setAppTags(appSlug, tags) {
58
+ const res = await tagFetch('PUT', `/z2h-cli/tags/${encodeURIComponent(appSlug)}`, { tags });
59
+ const data = (await res.json());
60
+ return data.tags ?? [];
61
+ }
62
+
63
+ export { TAG_TYPES, TAG_TYPE_LABELS, fetchAppTags, fetchTagCatalog, setAppTags };
package/dist/index.js CHANGED
@@ -8,11 +8,13 @@ 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');
14
15
  const commands_generateZ2hToken = require('./commands/generate-z2h-token.js');
15
16
  const commands_migrate = require('./commands/migrate.js');
17
+ const commands_tag = require('./commands/tag.js');
16
18
  const commands_edit = require('./commands/edit.js');
17
19
  const util_logger = require('./util/logger.js');
18
20
  const util_tracker = require('./util/tracker.js');
@@ -43,6 +45,10 @@ function stripConflictingEnvVars() {
43
45
  delete process.env[name];
44
46
  }
45
47
  }
48
+ /** commander collector for a repeatable `--tag` option. */
49
+ function collectTag(value, previous) {
50
+ return previous.concat(value);
51
+ }
46
52
  async function main() {
47
53
  stripConflictingEnvVars();
48
54
  const program = new commander.Command();
@@ -115,6 +121,21 @@ async function main() {
115
121
  await util_logger.fail(`deploy failed: ${err.message}`, { command: 'deploy' }, 2);
116
122
  }
117
123
  });
124
+ program
125
+ .command('tag [app-name]')
126
+ .description("Set an app's category tags — app type & team (interactive unless --tag or --json is given)")
127
+ .option('--tag <tag>', 'set a tag (repeatable); full-replaces the tag set non-interactively', collectTag, [])
128
+ .option('--json', "read-only: print the popular catalog + the app's current tags as JSON (no prompt)")
129
+ .action(async (appName, opts) => {
130
+ try {
131
+ await util_tracker.trackDuration('z2h_app_tag_success', () => commands_tag.tagCommand(appName, opts), {
132
+ app_name: appName ?? util_appName.getCwdAppName(),
133
+ });
134
+ }
135
+ catch (err) {
136
+ await util_logger.fail(`tag failed: ${err.message}`, { command: 'tag' }, 1);
137
+ }
138
+ });
118
139
  program
119
140
  .command('create-workspace')
120
141
  .description(`Bootstrap the ${util_workspace.getWorkspaceHome()}/ yarn workspace (one-time setup)`)
@@ -214,6 +235,21 @@ async function main() {
214
235
  await util_logger.fail(`transfer-owner failed: ${err.message}`, { command: 'transfer-owner' }, 1);
215
236
  }
216
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
+ });
217
253
  program
218
254
  .command('migrate')
219
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
  }
@@ -0,0 +1,24 @@
1
+ /** The dimension a tag categorizes an app by. */
2
+ export type TagType = 'app_type' | 'team';
3
+ /** The tag dimensions, in prompt order. */
4
+ export declare const TAG_TYPES: TagType[];
5
+ /** Human labels for each dimension (CLI prompts / output). */
6
+ export declare const TAG_TYPE_LABELS: Record<TagType, string>;
7
+ /** A catalog entry: one selectable tag with its dimension + global usage count. */
8
+ export interface CatalogEntry {
9
+ name: string;
10
+ type: TagType;
11
+ count: number;
12
+ }
13
+ /** A tag assigned to an app — name + dimension. */
14
+ export interface AppTag {
15
+ name: string;
16
+ type: TagType;
17
+ }
18
+ /** The full closed tag catalog (app types + teams), with usage counts. */
19
+ export declare function fetchTagCatalog(): Promise<CatalogEntry[]>;
20
+ /** Tags currently assigned to an app (bare slug). */
21
+ export declare function fetchAppTags(appSlug: string): Promise<AppTag[]>;
22
+ /** Full-replace an app's tag set; returns the resulting tags. */
23
+ export declare function setAppTags(appSlug: string, tags: string[]): Promise<AppTag[]>;
24
+ //# sourceMappingURL=tags.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tags.d.ts","sourceRoot":"","sources":["../../src/util/tags.ts"],"names":[],"mappings":"AAGA,iDAAiD;AACjD,MAAM,MAAM,OAAO,GAAG,UAAU,GAAG,MAAM,CAAC;AAE1C,2CAA2C;AAC3C,eAAO,MAAM,SAAS,EAAE,OAAO,EAAyB,CAAC;AAEzD,8DAA8D;AAC9D,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,OAAO,EAAE,MAAM,CAGnD,CAAC;AAEF,mFAAmF;AACnF,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,OAAO,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf;AAED,mDAAmD;AACnD,MAAM,WAAW,MAAM;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,OAAO,CAAC;CACf;AAgDD,0EAA0E;AAC1E,wBAAsB,eAAe,IAAI,OAAO,CAAC,YAAY,EAAE,CAAC,CAI/D;AAED,qDAAqD;AACrD,wBAAsB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAIrE;AAED,iEAAiE;AACjE,wBAAsB,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAInF"}
@@ -0,0 +1,69 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
+
3
+ const constants = require('../constants.js');
4
+ const util_auth_authStore = require('./auth/auth-store.js');
5
+
6
+ /** The tag dimensions, in prompt order. */
7
+ const TAG_TYPES = ['app_type', 'team'];
8
+ /** Human labels for each dimension (CLI prompts / output). */
9
+ const TAG_TYPE_LABELS = {
10
+ app_type: 'App type',
11
+ team: 'Team',
12
+ };
13
+ /**
14
+ * Authenticated request to the bigbrain-zth tag endpoints. Mirrors the Bearer
15
+ * pattern used by the STS broker / lock-release calls, with network-error
16
+ * wrapping, auth-failure guidance, and response-body inclusion on failure.
17
+ */
18
+ async function tagFetch(method, path, body) {
19
+ const { token } = await util_auth_authStore.readZthAuth();
20
+ let res;
21
+ try {
22
+ res = await fetch(`${constants.brokerBaseUrl()}${path}`, {
23
+ method,
24
+ headers: {
25
+ Authorization: `Bearer ${token}`,
26
+ Accept: 'application/json',
27
+ ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
28
+ ...constants.routingHeaders(),
29
+ },
30
+ body: body === undefined ? undefined : JSON.stringify(body),
31
+ });
32
+ }
33
+ catch (err) {
34
+ const message = err instanceof Error ? err.message : String(err);
35
+ throw new Error(`Z2H tag request failed: ${message}`);
36
+ }
37
+ if (res.status === 401 || res.status === 403) {
38
+ throw new Error(`Z2H tag request rejected by the broker (${res.status}). Refresh your token by running 'z2h-cli generate-z2h-token'.`);
39
+ }
40
+ if (!res.ok) {
41
+ const text = await res.text();
42
+ throw new Error(`Z2H tag request returned ${res.status}: ${text.slice(0, 1024)}`);
43
+ }
44
+ return res;
45
+ }
46
+ /** The full closed tag catalog (app types + teams), with usage counts. */
47
+ async function fetchTagCatalog() {
48
+ const res = await tagFetch('GET', '/z2h-cli/tags');
49
+ const data = (await res.json());
50
+ return data.tags ?? [];
51
+ }
52
+ /** Tags currently assigned to an app (bare slug). */
53
+ async function fetchAppTags(appSlug) {
54
+ const res = await tagFetch('GET', `/z2h-cli/tags/${encodeURIComponent(appSlug)}`);
55
+ const data = (await res.json());
56
+ return data.tags ?? [];
57
+ }
58
+ /** Full-replace an app's tag set; returns the resulting tags. */
59
+ async function setAppTags(appSlug, tags) {
60
+ const res = await tagFetch('PUT', `/z2h-cli/tags/${encodeURIComponent(appSlug)}`, { tags });
61
+ const data = (await res.json());
62
+ return data.tags ?? [];
63
+ }
64
+
65
+ exports.TAG_TYPES = TAG_TYPES;
66
+ exports.TAG_TYPE_LABELS = TAG_TYPE_LABELS;
67
+ exports.fetchAppTags = fetchAppTags;
68
+ exports.fetchTagCatalog = fetchTagCatalog;
69
+ exports.setAppTags = setAppTags;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mondaydotcomorg/z2h-cli",
3
- "version": "0.13.0",
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
+ }
@@ -2,8 +2,9 @@ import path from 'node:path';
2
2
  import os from 'node:os';
3
3
  import { createInterface } from 'node:readline';
4
4
  import { pathExists, readJson } from 'fs-extra';
5
- import { info, output, setOutputMode, warn } from '../util/logger';
5
+ import { info, warn, output, setOutputMode, getOutputMode } from '../util/logger';
6
6
  import { resolveConsumerPaths } from '../shadow/paths';
7
+ import { fetchAppTags, fetchTagCatalog, setAppTags } from '../util/tags';
7
8
  import { type AssetManifestFile, type ManifestEntry } from '../util/manifest';
8
9
  import { uploadDir } from '../util/s3/upload';
9
10
  import { BUCKET_WRITE_NAME, BUCKET_READ_DNS_URL, brokerBaseUrl, routingHeaders } from '../constants';
@@ -15,6 +16,7 @@ import { getRemoteUrl, guardCleanTree, pullAndResolve, pushToRemote, initRepo }
15
16
  import { resolveGitEnv } from '../util/git/git-env';
16
17
  import { type RemoteType, validateRemoteOptions, resolveRemoteUrl } from '../util/git/remote-options';
17
18
  import { setupS3Client } from '../util/s3/client';
19
+ import { promptForTags } from './tag';
18
20
  import { buildCommand } from './build';
19
21
 
20
22
  export interface DeployOptions {
@@ -158,6 +160,10 @@ export async function deployCommand(opts: DeployOptions = {}): Promise<void> {
158
160
  await releaseLockBestEffort(appName, token);
159
161
  }
160
162
 
163
+ // Tags are a post-deploy concern: the S3 upload has already succeeded, so a
164
+ // tag failure never fails the deploy (handled inside).
165
+ await handleDeployTags(appName);
166
+
161
167
  // The host MF parses the app name from the URL and prepends `mf-` itself
162
168
  // before looking it up in the manifest. So the user-facing URL must use
163
169
  // the consumer's de-scoped package name (no `mf-` prefix, no `@scope/`).
@@ -193,6 +199,30 @@ async function registerApp(
193
199
  }
194
200
  }
195
201
 
202
+ const TAG_NUDGE = "run 'z2h-cli tag' to categorize this app (app type & team)";
203
+
204
+ async function handleDeployTags(appSlug: string): Promise<void> {
205
+ try {
206
+ const interactive = getOutputMode() === 'human' && Boolean(process.stdin.isTTY);
207
+ if (!interactive) {
208
+ info(TAG_NUDGE);
209
+ return;
210
+ }
211
+
212
+ const current = await fetchAppTags(appSlug);
213
+ if (current.length > 0) {
214
+ return; // already tagged — don't prompt on every redeploy
215
+ }
216
+
217
+ const catalog = await fetchTagCatalog();
218
+ const selected = await promptForTags(appSlug, catalog, current);
219
+ const tags = await setAppTags(appSlug, selected);
220
+ info(tags.length > 0 ? `tags set: ${tags.map(t => t.name).join(', ')}` : TAG_NUDGE);
221
+ } catch (err) {
222
+ warn(`could not update tags: ${(err as Error).message}`);
223
+ }
224
+ }
225
+
196
226
  // Acquire the per-app deploy lock. Throws a friendly error on 409 (someone else is deploying)
197
227
  // so the deploy stops and the caller can retry; the backend TTL backstops a crashed holder.
198
228
  async function acquireDeployLock(appName: string, token: string): Promise<void> {
@@ -0,0 +1,134 @@
1
+ import readline from 'node:readline/promises';
2
+ import { stdin, stdout } from 'node:process';
3
+ import { readJson } from 'fs-extra';
4
+ import { resolveConsumerPaths } from '../shadow/paths';
5
+ import { mfAppName } from '../util/app-name';
6
+ import { info, output, setOutputMode } from '../util/logger';
7
+ import {
8
+ fetchAppTags,
9
+ fetchTagCatalog,
10
+ setAppTags,
11
+ TAG_TYPES,
12
+ TAG_TYPE_LABELS,
13
+ type AppTag,
14
+ type CatalogEntry,
15
+ } from '../util/tags';
16
+
17
+ export interface TagOptions {
18
+ /** Repeatable `--tag` — sets the full tag set non-interactively. */
19
+ tag?: string[];
20
+ json?: boolean;
21
+ }
22
+
23
+ /** Render an app's tags as `name (Type), ...` — or `(none)`. */
24
+ function formatTags(tags: AppTag[]): string {
25
+ return tags.map(t => `${t.name} (${TAG_TYPE_LABELS[t.type]})`).join(', ') || '(none)';
26
+ }
27
+
28
+ /**
29
+ * Resolve the target app name: an explicit arg wins, else the consumer
30
+ * package.json `name` in the cwd. Normalized to the `mf-` bundle name the
31
+ * broker stores (via `mfAppName`), so the server finds the app.
32
+ */
33
+ async function resolveAppSlug(appArg?: string): Promise<string> {
34
+ if (appArg) {
35
+ return mfAppName(appArg);
36
+ }
37
+ const paths = resolveConsumerPaths(process.cwd());
38
+ let pkg: { name?: string };
39
+ try {
40
+ pkg = (await readJson(paths.consumerPkg)) as { name?: string };
41
+ } catch {
42
+ throw new Error(
43
+ 'Could not read a consumer package.json in the current directory. ' +
44
+ 'Either cd into your app folder or pass an app name: z2h-cli tag <app-name>.'
45
+ );
46
+ }
47
+ if (!pkg.name) {
48
+ throw new Error('Consumer package.json must have a "name" field (or pass an app name argument).');
49
+ }
50
+ return mfAppName(pkg.name);
51
+ }
52
+
53
+ /**
54
+ * Closed-list prompt: one numbered pick per dimension (app type, team). Enter
55
+ * keeps the current value for that dimension (or skips it if unset). Returns
56
+ * the full replacement set of tag names.
57
+ */
58
+ export async function promptForTags(appSlug: string, catalog: CatalogEntry[], current: AppTag[]): Promise<string[]> {
59
+ const rl = readline.createInterface({ input: stdin, output: stdout });
60
+ const selected: string[] = [];
61
+ try {
62
+ info(`Tagging "${appSlug}".`);
63
+ for (const type of TAG_TYPES) {
64
+ const options = catalog.filter(t => t.type === type);
65
+ if (options.length === 0) {
66
+ continue;
67
+ }
68
+ const currentName = current.find(c => c.type === type)?.name;
69
+ const label = TAG_TYPE_LABELS[type];
70
+ info(`\n${label}${currentName ? ` (current: ${currentName})` : ''}:`);
71
+ options.forEach((o, i) => info(` ${i + 1}) ${o.name}`));
72
+
73
+ const raw = await rl.question(
74
+ `Pick a ${label} (number or name; Enter to ${currentName ? 'keep current' : 'skip'}):\n> `
75
+ );
76
+ const trimmed = raw.trim();
77
+ if (!trimmed) {
78
+ if (currentName) {
79
+ selected.push(currentName);
80
+ }
81
+ continue;
82
+ }
83
+ // Accept either the 1-based number or the tag name (case-insensitive).
84
+ const idx = Number(trimmed) - 1;
85
+ const byNumber = Number.isInteger(idx) && idx >= 0 && idx < options.length ? options[idx] : undefined;
86
+ const chosen = byNumber ?? options.find(o => o.name.toLowerCase() === trimmed.toLowerCase());
87
+ if (chosen) {
88
+ selected.push(chosen.name);
89
+ } else {
90
+ info(`Invalid choice "${trimmed}" — ${currentName ? 'keeping current' : 'skipping'} ${label}.`);
91
+ if (currentName) {
92
+ selected.push(currentName);
93
+ }
94
+ }
95
+ }
96
+ return selected;
97
+ } finally {
98
+ rl.close();
99
+ }
100
+ }
101
+
102
+ export async function tagCommand(appArg: string | undefined, opts: TagOptions): Promise<void> {
103
+ if (opts.json) {
104
+ setOutputMode('json');
105
+ }
106
+
107
+ const appSlug = await resolveAppSlug(appArg);
108
+
109
+ // Non-interactive write: `--tag` flags set the full set directly.
110
+ if (opts.tag && opts.tag.length > 0) {
111
+ const tags = await setAppTags(appSlug, opts.tag);
112
+ output(`[z2h-cli] tags for "${appSlug}": ${formatTags(tags)}`, { ok: true, appName: appSlug, tags });
113
+ return;
114
+ }
115
+
116
+ const [catalog, current] = await Promise.all([fetchTagCatalog(), fetchAppTags(appSlug)]);
117
+
118
+ // Read-only listing: agents (`--json`) and any non-TTY caller get the catalog
119
+ // + current tags without a prompt — then they set tags via `--tag`. This also
120
+ // avoids hanging on `readline` when no human is attached to stdin.
121
+ if (opts.json || !process.stdin.isTTY) {
122
+ output(
123
+ `[z2h-cli] "${appSlug}"\n catalog: ${
124
+ catalog.map(t => `${t.name} (${TAG_TYPE_LABELS[t.type]})`).join(', ') || '(none)'
125
+ }\n current: ${formatTags(current)}`,
126
+ { ok: true, appName: appSlug, catalog, current }
127
+ );
128
+ return;
129
+ }
130
+
131
+ const selected = await promptForTags(appSlug, catalog, current);
132
+ const tags = await setAppTags(appSlug, selected);
133
+ output(`[z2h-cli] tags for "${appSlug}": ${formatTags(tags)}`, { ok: true, appName: appSlug, tags });
134
+ }
package/src/index.ts CHANGED
@@ -8,11 +8,13 @@ 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';
14
15
  import { generateZ2HTokenCommand } from './commands/generate-z2h-token';
15
16
  import { migrateCommand } from './commands/migrate';
17
+ import { tagCommand } from './commands/tag';
16
18
  import { editCommand } from './commands/edit';
17
19
  import { errorLog, fail, getOutputMode } from './util/logger';
18
20
  import { trackDuration } from './util/tracker';
@@ -41,6 +43,11 @@ function stripConflictingEnvVars(): void {
41
43
  }
42
44
  }
43
45
 
46
+ /** commander collector for a repeatable `--tag` option. */
47
+ function collectTag(value: string, previous: string[]): string[] {
48
+ return previous.concat(value);
49
+ }
50
+
44
51
  async function main(): Promise<void> {
45
52
  stripConflictingEnvVars();
46
53
 
@@ -127,6 +134,21 @@ async function main(): Promise<void> {
127
134
  }
128
135
  });
129
136
 
137
+ program
138
+ .command('tag [app-name]')
139
+ .description("Set an app's category tags — app type & team (interactive unless --tag or --json is given)")
140
+ .option('--tag <tag>', 'set a tag (repeatable); full-replaces the tag set non-interactively', collectTag, [])
141
+ .option('--json', "read-only: print the popular catalog + the app's current tags as JSON (no prompt)")
142
+ .action(async (appName: string | undefined, opts: { tag?: string[]; json?: boolean }) => {
143
+ try {
144
+ await trackDuration('z2h_app_tag_success', () => tagCommand(appName, opts), {
145
+ app_name: appName ?? getCwdAppName(),
146
+ });
147
+ } catch (err) {
148
+ await fail(`tag failed: ${(err as Error).message}`, { command: 'tag' }, 1);
149
+ }
150
+ });
151
+
130
152
  program
131
153
  .command('create-workspace')
132
154
  .description(`Bootstrap the ${getWorkspaceHome()}/ yarn workspace (one-time setup)`)
@@ -233,6 +255,21 @@ async function main(): Promise<void> {
233
255
  }
234
256
  });
235
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
+
236
273
  program
237
274
  .command('migrate')
238
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) {