@mondaydotcomorg/z2h-cli 0.24.0 → 0.24.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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=generate-z2h-token.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"generate-z2h-token.test.d.ts","sourceRoot":"","sources":["../../../src/commands/__tests__/generate-z2h-token.test.ts"],"names":[],"mappings":""}
@@ -1 +1 @@
1
- {"version":3,"file":"create-workspace.d.ts","sourceRoot":"","sources":["../../src/commands/create-workspace.ts"],"names":[],"mappings":"AASA,wBAAsB,sBAAsB,IAAI,OAAO,CAAC,IAAI,CAAC,CA0C5D"}
1
+ {"version":3,"file":"create-workspace.d.ts","sourceRoot":"","sources":["../../src/commands/create-workspace.ts"],"names":[],"mappings":"AAaA,wBAAsB,sBAAsB,IAAI,OAAO,CAAC,IAAI,CAAC,CAuC5D"}
@@ -13,37 +13,35 @@ const _interopDefault = e => e && e.__esModule ? e : { default: e };
13
13
 
14
14
  const path__default = /*#__PURE__*/_interopDefault(path);
15
15
 
16
+ async function isPopulated(dir) {
17
+ return (await fs.pathExists(dir)) && (await fs.readdir(dir)).length > 0;
18
+ }
16
19
  async function createWorkspaceCommand() {
17
20
  const home = util_workspace.getWorkspaceHome();
18
21
  const templatesDir = util_templates.getTemplatesDir();
19
22
  const wsTemplatesDir = path__default.default.join(templatesDir, 'workspace');
20
- if (await fs.pathExists(home)) {
21
- const entries = await fs.readdir(home);
22
- if (entries.length > 0) {
23
- util_logger.output(`[z2h-cli] workspace already exists at ${home} — skipping`, {
24
- ok: true,
25
- workspace: home,
26
- });
27
- return;
28
- }
23
+ await fs.ensureDir(home);
24
+ const scaffolded = await util_workspace.isZ2hWorkspace(home);
25
+ if (!scaffolded) {
26
+ util_logger.info(`setting up your Z2H workspace`);
27
+ const pkgRaw = await promises.readFile(path__default.default.join(wsTemplatesDir, 'package.json.tmpl'), 'utf8');
28
+ await Promise.all([
29
+ fs.writeFile(path__default.default.join(home, 'package.json'), pkgRaw),
30
+ fs.copy(path__default.default.join(wsTemplatesDir, 'gitignore'), path__default.default.join(home, '.gitignore')),
31
+ fs.writeFile(path__default.default.join(home, '.yarnrc.yml'), util_npm_brokerConfig.workspaceYarnrcContents()),
32
+ fs.copy(path__default.default.join(wsTemplatesDir, 'nx.json'), path__default.default.join(home, 'nx.json')),
33
+ fs.copy(path__default.default.join(wsTemplatesDir, 'yarnlock'), path__default.default.join(home, 'yarn.lock')),
34
+ fs.copy(path__default.default.join(wsTemplatesDir, 'nvmrc'), path__default.default.join(home, '.nvmrc')),
35
+ fs.writeFile(path__default.default.join(home, '.npmrc'), util_npm_brokerConfig.workspaceNpmrcContents()),
36
+ fs.copy(path__default.default.join(wsTemplatesDir, 'README.md'), path__default.default.join(home, 'README.md')),
37
+ fs.copy(path__default.default.join(wsTemplatesDir, 'yarn-releases', 'yarn-4.5.1.cjs'), path__default.default.join(home, '.yarn', 'releases', 'yarn-4.5.1.cjs')),
38
+ ]);
29
39
  }
30
- else {
31
- await fs.ensureDir(home);
40
+ const hasWorkspaceNodeModules = await isPopulated(path__default.default.join(home, 'node_modules'));
41
+ if (!scaffolded || !hasWorkspaceNodeModules) {
42
+ util_logger.info(`installing workspace dependencies — this may take a minute`);
43
+ await util_npm_install.runYarnInstall({ cwd: home });
32
44
  }
33
- const pkgRaw = await promises.readFile(path__default.default.join(wsTemplatesDir, 'package.json.tmpl'), 'utf8');
34
- await Promise.all([
35
- fs.writeFile(path__default.default.join(home, 'package.json'), pkgRaw),
36
- fs.copy(path__default.default.join(wsTemplatesDir, 'gitignore'), path__default.default.join(home, '.gitignore')),
37
- fs.writeFile(path__default.default.join(home, '.yarnrc.yml'), util_npm_brokerConfig.workspaceYarnrcContents()),
38
- fs.copy(path__default.default.join(wsTemplatesDir, 'nx.json'), path__default.default.join(home, 'nx.json')),
39
- fs.copy(path__default.default.join(wsTemplatesDir, 'yarnlock'), path__default.default.join(home, 'yarn.lock')),
40
- fs.copy(path__default.default.join(wsTemplatesDir, 'nvmrc'), path__default.default.join(home, '.nvmrc')),
41
- fs.writeFile(path__default.default.join(home, '.npmrc'), util_npm_brokerConfig.workspaceNpmrcContents()),
42
- fs.copy(path__default.default.join(wsTemplatesDir, 'README.md'), path__default.default.join(home, 'README.md')),
43
- fs.copy(path__default.default.join(wsTemplatesDir, 'yarn-releases', 'yarn-4.5.1.cjs'), path__default.default.join(home, '.yarn', 'releases', 'yarn-4.5.1.cjs')),
44
- ]);
45
- util_logger.info(`setting up your Z2H workspace — this may take a minute...`);
46
- await util_npm_install.runYarnInstall({ cwd: home });
47
45
  util_logger.output(`Your workspace is ready! 🚀 Apps will live at ${home}`, {
48
46
  ok: true,
49
47
  workspace: home,
@@ -1 +1 @@
1
- {"version":3,"file":"generate-z2h-token.d.ts","sourceRoot":"","sources":["../../src/commands/generate-z2h-token.ts"],"names":[],"mappings":"AAOA,eAAO,MAAM,sBAAsB,yDAAyD,CAAC;AAC7F,eAAO,MAAM,mBAAmB,SAAU,CAAC;AAE3C,MAAM,WAAW,uBAAuB;IACtC;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,+DAA+D;IAC/D,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAwDD,wBAAsB,uBAAuB,CAAC,IAAI,GAAE,uBAA4B,GAAG,OAAO,CAAC,IAAI,CAAC,CA2B/F"}
1
+ {"version":3,"file":"generate-z2h-token.d.ts","sourceRoot":"","sources":["../../src/commands/generate-z2h-token.ts"],"names":[],"mappings":"AAOA,eAAO,MAAM,sBAAsB,yDAAyD,CAAC;AAC7F,eAAO,MAAM,mBAAmB,SAAU,CAAC;AAE3C,MAAM,WAAW,uBAAuB;IACtC;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,+DAA+D;IAC/D,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAwED,wBAAsB,uBAAuB,CAAC,IAAI,GAAE,uBAA4B,GAAG,OAAO,CAAC,IAAI,CAAC,CA2B/F"}
@@ -15,6 +15,9 @@ const DEFAULT_TOKEN_PAGE_URL = 'https://bigbrain.me/bigbrain-vibe/generate-z2h-t
15
15
  const LOOPBACK_TIMEOUT_MS = 120_000;
16
16
  async function promptForToken() {
17
17
  const rl = readline__default.default.createInterface({ input: node_process.stdin, output: node_process.stdout });
18
+ // Raw mode disables ISIG, so Ctrl-C never becomes a signal here. readline delivers the
19
+ // ^C byte as this event, and with no listener it only pauses. Exit code 130 is 128+SIGINT.
20
+ rl.on('SIGINT', () => process.exit(130));
18
21
  try {
19
22
  const raw = await rl.question('Paste your token here, then press Enter:\n> ');
20
23
  return raw.trim();
@@ -51,13 +54,23 @@ async function runLoopbackFlow(pageUrl) {
51
54
  util_logger.warn('could not auto-open the browser. please visit the URL above manually.');
52
55
  }
53
56
  util_logger.info('waiting for the page to send the token (Ctrl+C to fall back to manual paste)…');
57
+ // A SIGINT listener suppresses Node's default termination while it is attached. That is
58
+ // what turns the Ctrl+C offered above into the manual-paste fallback instead of a killed
59
+ // CLI. The finally below detaches it, so it covers only this wait.
60
+ let stopListening = () => { };
61
+ const cancelled = new Promise(resolve => {
62
+ const onSigint = () => resolve(null);
63
+ process.once('SIGINT', onSigint);
64
+ stopListening = () => process.removeListener('SIGINT', onSigint);
65
+ });
66
+ // `close()` below rejects a still-pending wait. Swallow it here so the loser of the race
67
+ // never becomes an unhandled rejection, which Node turns into a crash.
68
+ const arrived = loopback.waitForToken(LOOPBACK_TIMEOUT_MS).catch(() => null);
54
69
  try {
55
- return await loopback.waitForToken(LOOPBACK_TIMEOUT_MS);
56
- }
57
- catch {
58
- return null;
70
+ return await Promise.race([arrived, cancelled]);
59
71
  }
60
72
  finally {
73
+ stopListening();
61
74
  loopback.close();
62
75
  }
63
76
  }
@@ -1 +1 @@
1
- {"version":3,"file":"tag.d.ts","sourceRoot":"","sources":["../../src/commands/tag.ts"],"names":[],"mappings":"AAMA,OAAO,EAML,KAAK,MAAM,EACX,KAAK,YAAY,EAClB,MAAM,cAAc,CAAC;AAEtB,MAAM,WAAW,UAAU;IACzB,oEAAoE;IACpE,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;CAChB;AAgCD;;;;GAIG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CA0ClH;AAED,wBAAsB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CA4B5F"}
1
+ {"version":3,"file":"tag.d.ts","sourceRoot":"","sources":["../../src/commands/tag.ts"],"names":[],"mappings":"AAMA,OAAO,EAML,KAAK,MAAM,EACX,KAAK,YAAY,EAClB,MAAM,cAAc,CAAC;AAEtB,MAAM,WAAW,UAAU;IACzB,oEAAoE;IACpE,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;CAChB;AAgCD;;;;GAIG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CA6ClH;AAED,wBAAsB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CA4B5F"}
@@ -46,6 +46,9 @@ async function resolveAppSlug(appArg) {
46
46
  */
47
47
  async function promptForTags(appSlug, catalog, current) {
48
48
  const rl = readline__default.default.createInterface({ input: node_process.stdin, output: node_process.stdout });
49
+ // Raw mode disables ISIG, so Ctrl-C never becomes a signal here. readline delivers the
50
+ // ^C byte as this event, and with no listener it only pauses. Exit code 130 is 128+SIGINT.
51
+ rl.on('SIGINT', () => process.exit(130));
49
52
  const selected = [];
50
53
  try {
51
54
  util_logger.info(`Tagging "${appSlug}".`);
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=generate-z2h-token.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"generate-z2h-token.test.d.ts","sourceRoot":"","sources":["../../../../src/commands/__tests__/generate-z2h-token.test.ts"],"names":[],"mappings":""}
@@ -1 +1 @@
1
- {"version":3,"file":"create-workspace.d.ts","sourceRoot":"","sources":["../../../src/commands/create-workspace.ts"],"names":[],"mappings":"AASA,wBAAsB,sBAAsB,IAAI,OAAO,CAAC,IAAI,CAAC,CA0C5D"}
1
+ {"version":3,"file":"create-workspace.d.ts","sourceRoot":"","sources":["../../../src/commands/create-workspace.ts"],"names":[],"mappings":"AAaA,wBAAsB,sBAAsB,IAAI,OAAO,CAAC,IAAI,CAAC,CAuC5D"}
@@ -1,43 +1,41 @@
1
1
  import path from 'node:path';
2
2
  import { readFile } from 'node:fs/promises';
3
- import { pathExists, readdir, ensureDir, writeFile, copy } from 'fs-extra';
4
- import { output, info } from '../util/logger.mjs';
5
- import { getWorkspaceHome } from '../util/workspace.mjs';
3
+ import { ensureDir, writeFile, copy, pathExists, readdir } from 'fs-extra';
4
+ import { info, output } from '../util/logger.mjs';
5
+ import { getWorkspaceHome, isZ2hWorkspace } from '../util/workspace.mjs';
6
6
  import { getTemplatesDir } from '../util/templates.mjs';
7
7
  import { runYarnInstall } from '../util/npm/install.mjs';
8
8
  import { workspaceYarnrcContents, workspaceNpmrcContents } from '../util/npm/broker-config.mjs';
9
9
 
10
+ async function isPopulated(dir) {
11
+ return (await pathExists(dir)) && (await readdir(dir)).length > 0;
12
+ }
10
13
  async function createWorkspaceCommand() {
11
14
  const home = getWorkspaceHome();
12
15
  const templatesDir = getTemplatesDir();
13
16
  const wsTemplatesDir = path.join(templatesDir, 'workspace');
14
- if (await pathExists(home)) {
15
- const entries = await readdir(home);
16
- if (entries.length > 0) {
17
- output(`[z2h-cli] workspace already exists at ${home} — skipping`, {
18
- ok: true,
19
- workspace: home,
20
- });
21
- return;
22
- }
17
+ await ensureDir(home);
18
+ const scaffolded = await isZ2hWorkspace(home);
19
+ if (!scaffolded) {
20
+ info(`setting up your Z2H workspace`);
21
+ const pkgRaw = await readFile(path.join(wsTemplatesDir, 'package.json.tmpl'), 'utf8');
22
+ await Promise.all([
23
+ writeFile(path.join(home, 'package.json'), pkgRaw),
24
+ copy(path.join(wsTemplatesDir, 'gitignore'), path.join(home, '.gitignore')),
25
+ writeFile(path.join(home, '.yarnrc.yml'), workspaceYarnrcContents()),
26
+ copy(path.join(wsTemplatesDir, 'nx.json'), path.join(home, 'nx.json')),
27
+ copy(path.join(wsTemplatesDir, 'yarnlock'), path.join(home, 'yarn.lock')),
28
+ copy(path.join(wsTemplatesDir, 'nvmrc'), path.join(home, '.nvmrc')),
29
+ writeFile(path.join(home, '.npmrc'), workspaceNpmrcContents()),
30
+ copy(path.join(wsTemplatesDir, 'README.md'), path.join(home, 'README.md')),
31
+ copy(path.join(wsTemplatesDir, 'yarn-releases', 'yarn-4.5.1.cjs'), path.join(home, '.yarn', 'releases', 'yarn-4.5.1.cjs')),
32
+ ]);
23
33
  }
24
- else {
25
- await ensureDir(home);
34
+ const hasWorkspaceNodeModules = await isPopulated(path.join(home, 'node_modules'));
35
+ if (!scaffolded || !hasWorkspaceNodeModules) {
36
+ info(`installing workspace dependencies — this may take a minute`);
37
+ await runYarnInstall({ cwd: home });
26
38
  }
27
- const pkgRaw = await readFile(path.join(wsTemplatesDir, 'package.json.tmpl'), 'utf8');
28
- await Promise.all([
29
- writeFile(path.join(home, 'package.json'), pkgRaw),
30
- copy(path.join(wsTemplatesDir, 'gitignore'), path.join(home, '.gitignore')),
31
- writeFile(path.join(home, '.yarnrc.yml'), workspaceYarnrcContents()),
32
- copy(path.join(wsTemplatesDir, 'nx.json'), path.join(home, 'nx.json')),
33
- copy(path.join(wsTemplatesDir, 'yarnlock'), path.join(home, 'yarn.lock')),
34
- copy(path.join(wsTemplatesDir, 'nvmrc'), path.join(home, '.nvmrc')),
35
- writeFile(path.join(home, '.npmrc'), workspaceNpmrcContents()),
36
- copy(path.join(wsTemplatesDir, 'README.md'), path.join(home, 'README.md')),
37
- copy(path.join(wsTemplatesDir, 'yarn-releases', 'yarn-4.5.1.cjs'), path.join(home, '.yarn', 'releases', 'yarn-4.5.1.cjs')),
38
- ]);
39
- info(`setting up your Z2H workspace — this may take a minute...`);
40
- await runYarnInstall({ cwd: home });
41
39
  output(`Your workspace is ready! 🚀 Apps will live at ${home}`, {
42
40
  ok: true,
43
41
  workspace: home,
@@ -1 +1 @@
1
- {"version":3,"file":"generate-z2h-token.d.ts","sourceRoot":"","sources":["../../../src/commands/generate-z2h-token.ts"],"names":[],"mappings":"AAOA,eAAO,MAAM,sBAAsB,yDAAyD,CAAC;AAC7F,eAAO,MAAM,mBAAmB,SAAU,CAAC;AAE3C,MAAM,WAAW,uBAAuB;IACtC;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,+DAA+D;IAC/D,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAwDD,wBAAsB,uBAAuB,CAAC,IAAI,GAAE,uBAA4B,GAAG,OAAO,CAAC,IAAI,CAAC,CA2B/F"}
1
+ {"version":3,"file":"generate-z2h-token.d.ts","sourceRoot":"","sources":["../../../src/commands/generate-z2h-token.ts"],"names":[],"mappings":"AAOA,eAAO,MAAM,sBAAsB,yDAAyD,CAAC;AAC7F,eAAO,MAAM,mBAAmB,SAAU,CAAC;AAE3C,MAAM,WAAW,uBAAuB;IACtC;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,+DAA+D;IAC/D,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAwED,wBAAsB,uBAAuB,CAAC,IAAI,GAAE,uBAA4B,GAAG,OAAO,CAAC,IAAI,CAAC,CA2B/F"}
@@ -9,6 +9,9 @@ const DEFAULT_TOKEN_PAGE_URL = 'https://bigbrain.me/bigbrain-vibe/generate-z2h-t
9
9
  const LOOPBACK_TIMEOUT_MS = 120_000;
10
10
  async function promptForToken() {
11
11
  const rl = readline.createInterface({ input: stdin, output: stdout });
12
+ // Raw mode disables ISIG, so Ctrl-C never becomes a signal here. readline delivers the
13
+ // ^C byte as this event, and with no listener it only pauses. Exit code 130 is 128+SIGINT.
14
+ rl.on('SIGINT', () => process.exit(130));
12
15
  try {
13
16
  const raw = await rl.question('Paste your token here, then press Enter:\n> ');
14
17
  return raw.trim();
@@ -45,13 +48,23 @@ async function runLoopbackFlow(pageUrl) {
45
48
  warn('could not auto-open the browser. please visit the URL above manually.');
46
49
  }
47
50
  info('waiting for the page to send the token (Ctrl+C to fall back to manual paste)…');
51
+ // A SIGINT listener suppresses Node's default termination while it is attached. That is
52
+ // what turns the Ctrl+C offered above into the manual-paste fallback instead of a killed
53
+ // CLI. The finally below detaches it, so it covers only this wait.
54
+ let stopListening = () => { };
55
+ const cancelled = new Promise(resolve => {
56
+ const onSigint = () => resolve(null);
57
+ process.once('SIGINT', onSigint);
58
+ stopListening = () => process.removeListener('SIGINT', onSigint);
59
+ });
60
+ // `close()` below rejects a still-pending wait. Swallow it here so the loser of the race
61
+ // never becomes an unhandled rejection, which Node turns into a crash.
62
+ const arrived = loopback.waitForToken(LOOPBACK_TIMEOUT_MS).catch(() => null);
48
63
  try {
49
- return await loopback.waitForToken(LOOPBACK_TIMEOUT_MS);
50
- }
51
- catch {
52
- return null;
64
+ return await Promise.race([arrived, cancelled]);
53
65
  }
54
66
  finally {
67
+ stopListening();
55
68
  loopback.close();
56
69
  }
57
70
  }
@@ -1 +1 @@
1
- {"version":3,"file":"tag.d.ts","sourceRoot":"","sources":["../../../src/commands/tag.ts"],"names":[],"mappings":"AAMA,OAAO,EAML,KAAK,MAAM,EACX,KAAK,YAAY,EAClB,MAAM,cAAc,CAAC;AAEtB,MAAM,WAAW,UAAU;IACzB,oEAAoE;IACpE,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;CAChB;AAgCD;;;;GAIG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CA0ClH;AAED,wBAAsB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CA4B5F"}
1
+ {"version":3,"file":"tag.d.ts","sourceRoot":"","sources":["../../../src/commands/tag.ts"],"names":[],"mappings":"AAMA,OAAO,EAML,KAAK,MAAM,EACX,KAAK,YAAY,EAClB,MAAM,cAAc,CAAC;AAEtB,MAAM,WAAW,UAAU;IACzB,oEAAoE;IACpE,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;CAChB;AAgCD;;;;GAIG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CA6ClH;AAED,wBAAsB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CA4B5F"}
@@ -40,6 +40,9 @@ async function resolveAppSlug(appArg) {
40
40
  */
41
41
  async function promptForTags(appSlug, catalog, current) {
42
42
  const rl = readline.createInterface({ input: stdin, output: stdout });
43
+ // Raw mode disables ISIG, so Ctrl-C never becomes a signal here. readline delivers the
44
+ // ^C byte as this event, and with no listener it only pauses. Exit code 130 is 128+SIGINT.
45
+ rl.on('SIGINT', () => process.exit(130));
43
46
  const selected = [];
44
47
  try {
45
48
  info(`Tagging "${appSlug}".`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mondaydotcomorg/z2h-cli",
3
- "version": "0.24.0",
3
+ "version": "0.24.2",
4
4
  "bin": "./bin/z2h-cli.js",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import os from 'node:os';
3
- import { mkdtemp, rm, writeJson, readJson, ensureDir, pathExists } from 'fs-extra';
3
+ import { mkdtemp, rm, outputJson, readJson, ensureDir, pathExists } from 'fs-extra';
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
5
 
6
6
  import { runYarnInstall } from '../../util/npm/install';
@@ -51,14 +51,35 @@ describe('create-workspace', () => {
51
51
  expect(runYarnInstall).toHaveBeenCalledWith(expect.objectContaining({ cwd: tmp }));
52
52
  });
53
53
 
54
- it('skips gracefully if the target dir is non-empty', async () => {
54
+ it('scaffolds and installs even if the target dir has unrelated files (e.g. a mounted empty node_modules)', async () => {
55
55
  await ensureDir(tmp);
56
- await writeJson(path.join(tmp, 'somefile.json'), { hello: 'world' });
56
+ await ensureDir(path.join(tmp, 'node_modules'));
57
+
58
+ await createWorkspaceCommand();
59
+
60
+ expect(await pathExists(path.join(tmp, 'package.json'))).toBe(true);
61
+ expect(runYarnInstall).toHaveBeenCalledWith(expect.objectContaining({ cwd: tmp }));
62
+ });
63
+
64
+ it('skips install if package.json exists and node_modules is populated', async () => {
65
+ await createWorkspaceCommand();
66
+ vi.mocked(runYarnInstall).mockClear();
67
+ await outputJson(path.join(tmp, 'node_modules', 'some-dep', 'package.json'), { name: 'some-dep' });
68
+
69
+ await createWorkspaceCommand();
57
70
 
58
- await expect(createWorkspaceCommand()).resolves.toBeUndefined();
59
71
  expect(runYarnInstall).not.toHaveBeenCalled();
60
72
  });
61
73
 
74
+ it('reinstalls if package.json exists but node_modules is empty (e.g. wiped by an ephemeral mount)', async () => {
75
+ await createWorkspaceCommand();
76
+ vi.mocked(runYarnInstall).mockClear();
77
+
78
+ await createWorkspaceCommand();
79
+
80
+ expect(runYarnInstall).toHaveBeenCalledWith(expect.objectContaining({ cwd: tmp }));
81
+ });
82
+
62
83
  it('does not include z2h-cli in workspace devDeps (managed globally)', async () => {
63
84
  await createWorkspaceCommand();
64
85
 
@@ -0,0 +1,140 @@
1
+ import readline from 'node:readline/promises';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { generateZ2HTokenCommand } from '../generate-z2h-token';
5
+ import { startLoopbackServer } from '../../util/auth/loopback-server';
6
+ import { writeZthAuth } from '../../util/auth/write-auth';
7
+ import { openInBrowser } from '../../util/open-browser';
8
+ import { setOutputMode } from '../../util/logger';
9
+
10
+ vi.mock('node:readline/promises', () => ({
11
+ default: { createInterface: vi.fn() },
12
+ }));
13
+ vi.mock('../../util/auth/loopback-server', () => ({ startLoopbackServer: vi.fn() }));
14
+ vi.mock('../../util/auth/write-auth', async importOriginal => ({
15
+ ...(await importOriginal<typeof import('../../util/auth/write-auth')>()),
16
+ writeZthAuth: vi.fn().mockResolvedValue({ filePath: '/tmp/auth.json' }),
17
+ }));
18
+ vi.mock('../../util/open-browser', () => ({ openInBrowser: vi.fn().mockResolvedValue(undefined) }));
19
+
20
+ const PASTED_TOKEN = 'aaa.bbb.ccc';
21
+
22
+ /**
23
+ * Mirrors the real LoopbackHandle contract: `close()` rejects a wait that never
24
+ * received a token. A race that leaves that rejection unhandled crashes the process
25
+ * under Node's default unhandled-rejection policy, so the fake must reproduce it.
26
+ */
27
+ function fakeLoopback() {
28
+ let rejectPending: ((err: Error) => void) | null = null;
29
+ const close = vi.fn(() => {
30
+ rejectPending?.(new Error('Loopback server closed before token arrived'));
31
+ rejectPending = null;
32
+ });
33
+ return {
34
+ callbackUrl: 'http://127.0.0.1:1234/token',
35
+ waitForToken: vi.fn(
36
+ () =>
37
+ new Promise<string>((_res, rej) => {
38
+ rejectPending = rej;
39
+ })
40
+ ),
41
+ close,
42
+ };
43
+ }
44
+
45
+ describe('generate-z2h-token: Ctrl+C falls back to manual paste', () => {
46
+ let question: ReturnType<typeof vi.fn>;
47
+ let rlClose: ReturnType<typeof vi.fn>;
48
+ let rlOn: ReturnType<typeof vi.fn>;
49
+ const unhandled: unknown[] = [];
50
+ const onUnhandled = (err: unknown) => unhandled.push(err);
51
+
52
+ beforeEach(() => {
53
+ vi.clearAllMocks();
54
+ unhandled.length = 0;
55
+ process.on('unhandledRejection', onUnhandled);
56
+ setOutputMode('json');
57
+ question = vi.fn().mockResolvedValue(PASTED_TOKEN);
58
+ rlClose = vi.fn();
59
+ rlOn = vi.fn();
60
+ vi.mocked(readline.createInterface).mockReturnValue({
61
+ question,
62
+ close: rlClose,
63
+ on: rlOn,
64
+ } as unknown as ReturnType<typeof readline.createInterface>);
65
+ vi.mocked(startLoopbackServer).mockResolvedValue(fakeLoopback());
66
+ vi.mocked(writeZthAuth).mockResolvedValue({ filePath: '/tmp/auth.json' });
67
+ vi.mocked(openInBrowser).mockResolvedValue(undefined);
68
+ vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
69
+ });
70
+
71
+ afterEach(() => {
72
+ process.removeListener('unhandledRejection', onUnhandled);
73
+ setOutputMode('human');
74
+ vi.mocked(process.stdout.write).mockRestore();
75
+ });
76
+
77
+ it('cancels the loopback wait on SIGINT and saves the pasted token instead', async () => {
78
+ const done = generateZ2HTokenCommand();
79
+ // Let runLoopbackFlow reach its wait and attach the listener before interrupting.
80
+ await vi.waitFor(() => expect(process.listenerCount('SIGINT')).toBeGreaterThan(0));
81
+ process.emit('SIGINT');
82
+
83
+ await done;
84
+
85
+ expect(question).toHaveBeenCalledOnce();
86
+ expect(writeZthAuth).toHaveBeenCalledWith({ token: PASTED_TOKEN });
87
+ });
88
+
89
+ it('detaches its SIGINT listener so a later Ctrl+C is not swallowed', async () => {
90
+ const before = process.listenerCount('SIGINT');
91
+ const done = generateZ2HTokenCommand();
92
+ await vi.waitFor(() => expect(process.listenerCount('SIGINT')).toBe(before + 1));
93
+ process.emit('SIGINT');
94
+ await done;
95
+
96
+ expect(process.listenerCount('SIGINT')).toBe(before);
97
+ });
98
+
99
+ it('never leaves the losing race branch as an unhandled rejection', async () => {
100
+ const done = generateZ2HTokenCommand();
101
+ await vi.waitFor(() => expect(process.listenerCount('SIGINT')).toBeGreaterThan(0));
102
+ process.emit('SIGINT');
103
+ await done;
104
+ // close() rejected the pending wait; that rejection must already be swallowed.
105
+ await new Promise(r => setImmediate(r));
106
+
107
+ expect(unhandled).toEqual([]);
108
+ });
109
+
110
+ it('still uses the loopback token when one arrives before any Ctrl+C', async () => {
111
+ const handle = fakeLoopback();
112
+ handle.waitForToken.mockResolvedValue('xxx.yyy.zzz');
113
+ vi.mocked(startLoopbackServer).mockResolvedValue(handle);
114
+
115
+ await generateZ2HTokenCommand();
116
+
117
+ expect(question).not.toHaveBeenCalled();
118
+ expect(writeZthAuth).toHaveBeenCalledWith({ token: 'xxx.yyy.zzz' });
119
+ });
120
+
121
+ it('--no-browser skips the loopback entirely and prompts straight away', async () => {
122
+ await generateZ2HTokenCommand({ noBrowser: true });
123
+
124
+ expect(startLoopbackServer).not.toHaveBeenCalled();
125
+ expect(openInBrowser).not.toHaveBeenCalled();
126
+ expect(question).toHaveBeenCalledOnce();
127
+ });
128
+
129
+ // Raw mode swallows Ctrl-C unless the interface itself listens, so the prompt must
130
+ // register a handler or the CLI hangs there with no way out.
131
+ it('makes Ctrl-C exit the paste prompt instead of pausing it', async () => {
132
+ await generateZ2HTokenCommand({ noBrowser: true });
133
+
134
+ expect(rlOn).toHaveBeenCalledWith('SIGINT', expect.any(Function));
135
+ const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
136
+ rlOn.mock.calls.find(([event]) => event === 'SIGINT')?.[1]();
137
+ expect(exit).toHaveBeenCalledWith(130);
138
+ exit.mockRestore();
139
+ });
140
+ });
@@ -2,48 +2,49 @@ import path from 'node:path';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { ensureDir, writeFile, pathExists, readdir, copy } from 'fs-extra';
4
4
  import { info, output } from '../util/logger';
5
- import { getWorkspaceHome } from '../util/workspace';
5
+ import { getWorkspaceHome, isZ2hWorkspace } from '../util/workspace';
6
6
  import { getTemplatesDir } from '../util/templates';
7
7
  import { runYarnInstall } from '../util/npm/install';
8
8
  import { workspaceNpmrcContents, workspaceYarnrcContents } from '../util/npm/broker-config';
9
9
 
10
+ async function isPopulated(dir: string): Promise<boolean> {
11
+ return (await pathExists(dir)) && (await readdir(dir)).length > 0;
12
+ }
13
+
10
14
  export async function createWorkspaceCommand(): Promise<void> {
11
15
  const home = getWorkspaceHome();
12
16
  const templatesDir = getTemplatesDir();
13
17
  const wsTemplatesDir = path.join(templatesDir, 'workspace');
14
18
 
15
- if (await pathExists(home)) {
16
- const entries = await readdir(home);
17
- if (entries.length > 0) {
18
- output(`[z2h-cli] workspace already exists at ${home} — skipping`, {
19
- ok: true,
20
- workspace: home,
21
- });
22
- return;
23
- }
24
- } else {
25
- await ensureDir(home);
19
+ await ensureDir(home);
20
+
21
+ const scaffolded = await isZ2hWorkspace(home);
22
+
23
+ if (!scaffolded) {
24
+ info(`setting up your Z2H workspace`);
25
+ const pkgRaw = await readFile(path.join(wsTemplatesDir, 'package.json.tmpl'), 'utf8');
26
+
27
+ await Promise.all([
28
+ writeFile(path.join(home, 'package.json'), pkgRaw),
29
+ copy(path.join(wsTemplatesDir, 'gitignore'), path.join(home, '.gitignore')),
30
+ writeFile(path.join(home, '.yarnrc.yml'), workspaceYarnrcContents()),
31
+ copy(path.join(wsTemplatesDir, 'nx.json'), path.join(home, 'nx.json')),
32
+ copy(path.join(wsTemplatesDir, 'yarnlock'), path.join(home, 'yarn.lock')),
33
+ copy(path.join(wsTemplatesDir, 'nvmrc'), path.join(home, '.nvmrc')),
34
+ writeFile(path.join(home, '.npmrc'), workspaceNpmrcContents()),
35
+ copy(path.join(wsTemplatesDir, 'README.md'), path.join(home, 'README.md')),
36
+ copy(
37
+ path.join(wsTemplatesDir, 'yarn-releases', 'yarn-4.5.1.cjs'),
38
+ path.join(home, '.yarn', 'releases', 'yarn-4.5.1.cjs')
39
+ ),
40
+ ]);
26
41
  }
27
42
 
28
- const pkgRaw = await readFile(path.join(wsTemplatesDir, 'package.json.tmpl'), 'utf8');
29
-
30
- await Promise.all([
31
- writeFile(path.join(home, 'package.json'), pkgRaw),
32
- copy(path.join(wsTemplatesDir, 'gitignore'), path.join(home, '.gitignore')),
33
- writeFile(path.join(home, '.yarnrc.yml'), workspaceYarnrcContents()),
34
- copy(path.join(wsTemplatesDir, 'nx.json'), path.join(home, 'nx.json')),
35
- copy(path.join(wsTemplatesDir, 'yarnlock'), path.join(home, 'yarn.lock')),
36
- copy(path.join(wsTemplatesDir, 'nvmrc'), path.join(home, '.nvmrc')),
37
- writeFile(path.join(home, '.npmrc'), workspaceNpmrcContents()),
38
- copy(path.join(wsTemplatesDir, 'README.md'), path.join(home, 'README.md')),
39
- copy(
40
- path.join(wsTemplatesDir, 'yarn-releases', 'yarn-4.5.1.cjs'),
41
- path.join(home, '.yarn', 'releases', 'yarn-4.5.1.cjs')
42
- ),
43
- ]);
44
-
45
- info(`setting up your Z2H workspace — this may take a minute...`);
46
- await runYarnInstall({ cwd: home });
43
+ const hasWorkspaceNodeModules = await isPopulated(path.join(home, 'node_modules'));
44
+ if (!scaffolded || !hasWorkspaceNodeModules) {
45
+ info(`installing workspace dependencies — this may take a minute`);
46
+ await runYarnInstall({ cwd: home });
47
+ }
47
48
 
48
49
  output(`Your workspace is ready! 🚀 Apps will live at ${home}`, {
49
50
  ok: true,
@@ -22,6 +22,9 @@ export interface GenerateZ2HTokenOptions {
22
22
 
23
23
  async function promptForToken(): Promise<string> {
24
24
  const rl = readline.createInterface({ input: stdin, output: stdout });
25
+ // Raw mode disables ISIG, so Ctrl-C never becomes a signal here. readline delivers the
26
+ // ^C byte as this event, and with no listener it only pauses. Exit code 130 is 128+SIGINT.
27
+ rl.on('SIGINT', () => process.exit(130));
25
28
  try {
26
29
  const raw = await rl.question('Paste your token here, then press Enter:\n> ');
27
30
  return raw.trim();
@@ -65,11 +68,24 @@ async function runLoopbackFlow(pageUrl: string): Promise<string | null> {
65
68
 
66
69
  info('waiting for the page to send the token (Ctrl+C to fall back to manual paste)…');
67
70
 
71
+ // A SIGINT listener suppresses Node's default termination while it is attached. That is
72
+ // what turns the Ctrl+C offered above into the manual-paste fallback instead of a killed
73
+ // CLI. The finally below detaches it, so it covers only this wait.
74
+ let stopListening = (): void => {};
75
+ const cancelled = new Promise<null>(resolve => {
76
+ const onSigint = (): void => resolve(null);
77
+ process.once('SIGINT', onSigint);
78
+ stopListening = () => process.removeListener('SIGINT', onSigint);
79
+ });
80
+
81
+ // `close()` below rejects a still-pending wait. Swallow it here so the loser of the race
82
+ // never becomes an unhandled rejection, which Node turns into a crash.
83
+ const arrived = loopback.waitForToken(LOOPBACK_TIMEOUT_MS).catch(() => null);
84
+
68
85
  try {
69
- return await loopback.waitForToken(LOOPBACK_TIMEOUT_MS);
70
- } catch {
71
- return null;
86
+ return await Promise.race([arrived, cancelled]);
72
87
  } finally {
88
+ stopListening();
73
89
  loopback.close();
74
90
  }
75
91
  }
@@ -56,6 +56,9 @@ async function resolveAppSlug(appArg?: string): Promise<string> {
56
56
  */
57
57
  export async function promptForTags(appSlug: string, catalog: CatalogEntry[], current: AppTag[]): Promise<string[]> {
58
58
  const rl = readline.createInterface({ input: stdin, output: stdout });
59
+ // Raw mode disables ISIG, so Ctrl-C never becomes a signal here. readline delivers the
60
+ // ^C byte as this event, and with no listener it only pauses. Exit code 130 is 128+SIGINT.
61
+ rl.on('SIGINT', () => process.exit(130));
59
62
  const selected: string[] = [];
60
63
  try {
61
64
  info(`Tagging "${appSlug}".`);