@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.
- package/dist/commands/delete.d.ts +6 -0
- package/dist/commands/delete.d.ts.map +1 -0
- package/dist/commands/delete.js +53 -0
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +26 -0
- package/dist/commands/tag.d.ts +14 -0
- package/dist/commands/tag.d.ts.map +1 -0
- package/dist/commands/tag.js +114 -0
- package/dist/esm/commands/delete.d.ts +6 -0
- package/dist/esm/commands/delete.d.ts.map +1 -0
- package/dist/esm/commands/delete.mjs +51 -0
- package/dist/esm/commands/deploy.d.ts.map +1 -1
- package/dist/esm/commands/deploy.mjs +27 -1
- package/dist/esm/commands/tag.d.ts +14 -0
- package/dist/esm/commands/tag.d.ts.map +1 -0
- package/dist/esm/commands/tag.mjs +107 -0
- package/dist/esm/index.mjs +36 -0
- package/dist/esm/util/git/repo.d.ts.map +1 -1
- package/dist/esm/util/git/repo.mjs +10 -0
- package/dist/esm/util/tags.d.ts +24 -0
- package/dist/esm/util/tags.d.ts.map +1 -0
- package/dist/esm/util/tags.mjs +63 -0
- package/dist/index.js +36 -0
- package/dist/util/git/repo.d.ts.map +1 -1
- package/dist/util/git/repo.js +10 -0
- package/dist/util/tags.d.ts +24 -0
- package/dist/util/tags.d.ts.map +1 -0
- package/dist/util/tags.js +69 -0
- package/package.json +1 -1
- package/src/commands/delete.ts +63 -0
- package/src/commands/deploy.ts +31 -1
- package/src/commands/tag.ts +134 -0
- package/src/index.ts +37 -0
- package/src/util/git/__tests__/repo.test.ts +23 -1
- package/src/util/git/repo.ts +12 -0
- package/src/util/tags.ts +94 -0
|
@@ -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,
|
|
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"}
|
package/dist/util/git/repo.js
CHANGED
|
@@ -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
|
@@ -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/commands/deploy.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
package/src/util/git/repo.ts
CHANGED
|
@@ -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) {
|