@hs-x/cli 0.1.2 → 0.2.0

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.
Files changed (137) hide show
  1. package/dist/account-store.d.ts +45 -22
  2. package/dist/account-store.d.ts.map +1 -1
  3. package/dist/account-store.js +73 -16
  4. package/dist/account-store.js.map +1 -1
  5. package/dist/cli/index.d.ts.map +1 -1
  6. package/dist/cli/index.js +170 -13
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/cli/kit.d.ts +1 -1
  9. package/dist/cli/kit.d.ts.map +1 -1
  10. package/dist/cli/kit.js.map +1 -1
  11. package/dist/commands/account.d.ts +6 -6
  12. package/dist/commands/account.d.ts.map +1 -1
  13. package/dist/commands/account.js +25 -23
  14. package/dist/commands/account.js.map +1 -1
  15. package/dist/commands/api.d.ts +1 -1
  16. package/dist/commands/completion.d.ts.map +1 -1
  17. package/dist/commands/completion.js +6 -4
  18. package/dist/commands/completion.js.map +1 -1
  19. package/dist/commands/connect.d.ts +16 -0
  20. package/dist/commands/connect.d.ts.map +1 -1
  21. package/dist/commands/connect.js +143 -17
  22. package/dist/commands/connect.js.map +1 -1
  23. package/dist/commands/control-plane-read.d.ts +3 -3
  24. package/dist/commands/control-plane-read.d.ts.map +1 -1
  25. package/dist/commands/control-plane-read.js +21 -33
  26. package/dist/commands/control-plane-read.js.map +1 -1
  27. package/dist/commands/deploy-promote.d.ts +1 -1
  28. package/dist/commands/deploy-promote.d.ts.map +1 -1
  29. package/dist/commands/deploy-promote.js +3 -3
  30. package/dist/commands/deploy-promote.js.map +1 -1
  31. package/dist/commands/deploy.d.ts.map +1 -1
  32. package/dist/commands/deploy.js +39 -2
  33. package/dist/commands/deploy.js.map +1 -1
  34. package/dist/commands/dev.d.ts.map +1 -1
  35. package/dist/commands/dev.js +11 -0
  36. package/dist/commands/dev.js.map +1 -1
  37. package/dist/commands/doctor.d.ts +1 -1
  38. package/dist/commands/doctor.js +2 -2
  39. package/dist/commands/doctor.js.map +1 -1
  40. package/dist/commands/flags.d.ts +1 -1
  41. package/dist/commands/flags.d.ts.map +1 -1
  42. package/dist/commands/flags.js +59 -35
  43. package/dist/commands/flags.js.map +1 -1
  44. package/dist/commands/help-command.d.ts +14 -0
  45. package/dist/commands/help-command.d.ts.map +1 -1
  46. package/dist/commands/help-command.js +274 -20
  47. package/dist/commands/help-command.js.map +1 -1
  48. package/dist/commands/init.d.ts +11 -8
  49. package/dist/commands/init.d.ts.map +1 -1
  50. package/dist/commands/init.js +435 -246
  51. package/dist/commands/init.js.map +1 -1
  52. package/dist/commands/link.d.ts +1 -26
  53. package/dist/commands/link.d.ts.map +1 -1
  54. package/dist/commands/link.js +11 -236
  55. package/dist/commands/link.js.map +1 -1
  56. package/dist/commands/login.d.ts +4 -12
  57. package/dist/commands/login.d.ts.map +1 -1
  58. package/dist/commands/login.js +196 -390
  59. package/dist/commands/login.js.map +1 -1
  60. package/dist/commands/migrate.d.ts +2 -7
  61. package/dist/commands/migrate.d.ts.map +1 -1
  62. package/dist/commands/migrate.js +84 -201
  63. package/dist/commands/migrate.js.map +1 -1
  64. package/dist/commands/rollback.d.ts +1 -20
  65. package/dist/commands/rollback.d.ts.map +1 -1
  66. package/dist/commands/rollback.js +6 -275
  67. package/dist/commands/rollback.js.map +1 -1
  68. package/dist/commands/status.d.ts +1 -1
  69. package/dist/commands/status.js +1 -1
  70. package/dist/commands/status.js.map +1 -1
  71. package/dist/commands/unlink.d.ts +1 -21
  72. package/dist/commands/unlink.d.ts.map +1 -1
  73. package/dist/commands/unlink.js +12 -94
  74. package/dist/commands/unlink.js.map +1 -1
  75. package/dist/commands/update.js +7 -7
  76. package/dist/commands/update.js.map +1 -1
  77. package/dist/commands/validate.d.ts +2 -2
  78. package/dist/commands/validate.d.ts.map +1 -1
  79. package/dist/commands/validate.js +1 -1
  80. package/dist/commands/validate.js.map +1 -1
  81. package/dist/config.d.ts +1 -0
  82. package/dist/config.d.ts.map +1 -1
  83. package/dist/config.js +7 -0
  84. package/dist/config.js.map +1 -1
  85. package/dist/constants.d.ts +1 -1
  86. package/dist/constants.d.ts.map +1 -1
  87. package/dist/constants.js +5 -1
  88. package/dist/constants.js.map +1 -1
  89. package/dist/control-plane-fetch.d.ts.map +1 -1
  90. package/dist/control-plane-fetch.js +2 -2
  91. package/dist/control-plane-fetch.js.map +1 -1
  92. package/dist/control-plane-loader.d.ts +1 -1
  93. package/dist/control-plane-loader.js +1 -1
  94. package/dist/errors-registry.js +1 -1
  95. package/dist/errors-registry.js.map +1 -1
  96. package/dist/help.d.ts.map +1 -1
  97. package/dist/help.js +3 -2
  98. package/dist/help.js.map +1 -1
  99. package/dist/init/templates.d.ts +11 -3
  100. package/dist/init/templates.d.ts.map +1 -1
  101. package/dist/init/templates.js +96 -25
  102. package/dist/init/templates.js.map +1 -1
  103. package/dist/project-binding.d.ts +17 -0
  104. package/dist/project-binding.d.ts.map +1 -0
  105. package/dist/project-binding.js +30 -0
  106. package/dist/project-binding.js.map +1 -0
  107. package/dist/prompt.d.ts +21 -0
  108. package/dist/prompt.d.ts.map +1 -1
  109. package/dist/prompt.js +186 -50
  110. package/dist/prompt.js.map +1 -1
  111. package/dist/reporter/human.d.ts +5 -0
  112. package/dist/reporter/human.d.ts.map +1 -1
  113. package/dist/reporter/human.js +48 -18
  114. package/dist/reporter/human.js.map +1 -1
  115. package/dist/reporter/json.d.ts +6 -0
  116. package/dist/reporter/json.d.ts.map +1 -1
  117. package/dist/reporter/json.js +16 -0
  118. package/dist/reporter/json.js.map +1 -1
  119. package/dist/reporter/style.d.ts +1 -1
  120. package/dist/reporter/style.d.ts.map +1 -1
  121. package/dist/reporter/style.js +10 -3
  122. package/dist/reporter/style.js.map +1 -1
  123. package/dist/reporter/types.d.ts +9 -0
  124. package/dist/reporter/types.d.ts.map +1 -1
  125. package/dist/reporter/types.js.map +1 -1
  126. package/dist/services/account-store.d.ts +9 -4
  127. package/dist/services/account-store.d.ts.map +1 -1
  128. package/dist/services/account-store.js +46 -22
  129. package/dist/services/account-store.js.map +1 -1
  130. package/dist/services/control-plane.js +3 -2
  131. package/dist/services/control-plane.js.map +1 -1
  132. package/dist/services/live.d.ts +1 -1
  133. package/dist/services/reporter.d.ts +3 -0
  134. package/dist/services/reporter.d.ts.map +1 -1
  135. package/dist/services/reporter.js +2 -0
  136. package/dist/services/reporter.js.map +1 -1
  137. package/package.json +11 -8
@@ -1,176 +1,15 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { mkdir, stat, writeFile } from 'node:fs/promises';
3
- import { basename, dirname, join, resolve } from 'node:path';
4
2
  import * as Args from '@effect/cli/Args';
5
3
  import * as Command from '@effect/cli/Command';
6
4
  import * as Options from '@effect/cli/Options';
5
+ import { FileSystem, Path } from '@effect/platform';
7
6
  import { Effect, Option } from 'effect';
8
- import { exitWith } from '../cli-error.js';
9
- import { cwdOption, jsonOption, runHandler, runQuarantined } from '../cli/kit.js';
10
- import { isInteractive, promptConfirm, promptSelect, promptText } from '../prompt.js';
11
- import { createReporter } from '../reporter/index.js';
7
+ import { cliError, exitWith } from '../cli-error.js';
8
+ import { cwdOption, jsonOption, runHandler } from '../cli/kit.js';
9
+ import { getTemplate, resolveTemplateType, TEMPLATES, } from '../init/templates.js';
10
+ import { BACK, isInteractive, promptConfirm, promptSelect, promptText } from '../prompt.js';
12
11
  import { Cwd } from '../services/cwd.js';
13
- export async function initProject({ argv, cwd, json, }) {
14
- const { getTemplate, resolveTemplateType, TEMPLATES } = await import('../init/templates.js');
15
- const positional = argv[1] && !argv[1].startsWith('-') ? argv[1] : undefined;
16
- const flagName = resolveFlag(argv, '--name');
17
- const flagTemplateRaw = resolveFlag(argv, '--template') ?? resolveFlag(argv, '--type');
18
- const flagTemplate = resolveTemplateType(flagTemplateRaw);
19
- if (flagTemplateRaw && !flagTemplate) {
20
- throw new Error(`Unknown app type: ${flagTemplateRaw}. Use workflow-action, sync-source, or empty.`);
21
- }
22
- const flagAppName = resolveFlag(argv, '--app-name') ?? resolveFlag(argv, '--display-name');
23
- const flagYes = argv.includes('--yes') || argv.includes('-y');
24
- const flagSkipInstall = argv.includes('--no-install');
25
- const force = argv.includes('--force');
26
- const interactive = !json && isInteractive();
27
- let workingCwd = cwd;
28
- if (positional &&
29
- (positional.includes('/') || positional.startsWith('.') || positional.startsWith('~'))) {
30
- const expanded = positional.startsWith('~')
31
- ? join(process.env.HOME ?? '', positional.slice(1))
32
- : positional;
33
- const abs = resolve(cwd, expanded);
34
- workingCwd = dirname(abs);
35
- }
36
- const reporter = createReporter({ command: 'init', argv, entry: true });
37
- let bannerShown = false;
38
- if (interactive) {
39
- reporter.banner();
40
- bannerShown = true;
41
- }
42
- const rawName = positional ?? flagName;
43
- let name;
44
- if (rawName) {
45
- name = basename(rawName.startsWith('~')
46
- ? join(process.env.HOME ?? '', rawName.slice(1))
47
- : resolve(workingCwd, rawName));
48
- const validationError = validateProjectName(name);
49
- if (validationError) {
50
- const code = 'HSX_E_INPUT_BAD_NAME';
51
- reporter.error(code, `Invalid project name: ${name}`, {
52
- hint: validationError,
53
- docs_url: `https://hs-x.dev/errors/${code}`,
54
- });
55
- reporter.done(undefined, 10);
56
- return { exitCode: 10 };
57
- }
58
- }
59
- if (!name) {
60
- if (interactive) {
61
- const answer = await promptText({
62
- message: 'Project name',
63
- default: 'hsx-app',
64
- validate: validateProjectName,
65
- });
66
- if (answer === undefined)
67
- return cancelledResult(argv, 'init');
68
- name = answer;
69
- }
70
- else {
71
- name = 'hsx-app';
72
- }
73
- }
74
- let templateId = flagTemplate ?? 'deals-tagger';
75
- if (!flagTemplate && interactive) {
76
- const picked = await promptSelect({
77
- message: 'What are you building?',
78
- default: 'deals-tagger',
79
- options: TEMPLATES.map((t) => ({
80
- value: t.id,
81
- label: t.label,
82
- description: t.description,
83
- })),
84
- });
85
- if (picked === undefined)
86
- return cancelledResult(argv, 'init');
87
- templateId = picked;
88
- }
89
- let appName = flagAppName;
90
- if (appName === undefined && interactive) {
91
- const answer = await promptText({
92
- message: 'HubSpot app name',
93
- default: toDisplayName(name),
94
- validate(value) {
95
- return value.trim().length === 0 ? 'Enter an app name.' : undefined;
96
- },
97
- });
98
- if (answer === undefined)
99
- return cancelledResult(argv, 'init');
100
- appName = answer.trim();
101
- }
102
- appName = appName?.trim() || toDisplayName(name);
103
- let shouldInstall = false;
104
- if (!flagSkipInstall && interactive && !flagYes) {
105
- const confirmed = await promptConfirm({
106
- message: 'Install dependencies now?',
107
- default: true,
108
- });
109
- if (confirmed === undefined)
110
- return cancelledResult(argv, 'init');
111
- shouldInstall = confirmed;
112
- }
113
- else if (flagYes && !flagSkipInstall) {
114
- shouldInstall = true;
115
- }
116
- const root = resolve(workingCwd, name);
117
- const configPath = join(root, 'hsx.config.ts');
118
- const configExists = await stat(configPath).then(() => true, () => false);
119
- if (configExists && !force) {
120
- const code = 'HSX_E_INIT_DIR_NOT_EMPTY';
121
- reporter.error(code, `Project already exists at ${root}.`, {
122
- hint: `Pick a new directory, delete the existing one (rm -rf ${root}), or pass --force to overwrite.`,
123
- docs_url: `https://hs-x.dev/errors/${code}`,
124
- });
125
- reporter.done(undefined, 10);
126
- return { exitCode: 10 };
127
- }
128
- if (interactive)
129
- reporter.header(name);
130
- else if (!json && !bannerShown) {
131
- reporter.banner();
132
- reporter.header(name);
133
- }
134
- const template = getTemplate(templateId);
135
- const files = template.files(name, { appName });
136
- await mkdir(join(root, 'src', 'workers'), { recursive: true });
137
- const scaffoldStep = reporter.step('Scaffolding project');
138
- for (const file of files) {
139
- const filePath = join(root, file.path);
140
- await mkdir(dirname(filePath), { recursive: true });
141
- await writeFile(filePath, file.contents, { flag: force ? 'w' : 'wx' });
142
- }
143
- scaffoldStep.ok(`${files.length} file${files.length === 1 ? '' : 's'} ${root}`);
144
- if (shouldInstall) {
145
- const installStep = reporter.step('Installing dependencies');
146
- try {
147
- await runInstall(root);
148
- installStep.ok();
149
- }
150
- catch (error) {
151
- const message = error instanceof Error ? error.message : String(error);
152
- installStep.fail(message);
153
- reporter.warn('HSX_W_INIT_INSTALL_FAILED', `Dependency install failed; you can retry with: cd ${name} && bun install`);
154
- }
155
- }
156
- const result = {
157
- ok: true,
158
- command: 'init',
159
- project: { name, appName, root, template: templateId, installed: shouldInstall },
160
- };
161
- if (json) {
162
- process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
163
- return { exitCode: 0 };
164
- }
165
- reporter.info('');
166
- reporter.info('Next steps:');
167
- reporter.info(` cd ${name}`);
168
- if (!shouldInstall)
169
- reporter.info(' bun install');
170
- reporter.info(' hs-x dev');
171
- reporter.done('Created');
172
- return { exitCode: 0 };
173
- }
12
+ import { Reporter } from '../services/reporter.js';
174
13
  function validateProjectName(input) {
175
14
  if (!input)
176
15
  return 'Name is required.';
@@ -185,53 +24,66 @@ function toDisplayName(name) {
185
24
  .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
186
25
  .join(' ');
187
26
  }
27
+ /** Resolve an optional enum flag against its allowed values (trimmed, lowercased). */
28
+ function resolveEnumFlag(value, allowed) {
29
+ const raw = Option.getOrUndefined(value)?.trim().toLowerCase();
30
+ return raw && allowed.includes(raw) ? raw : undefined;
31
+ }
32
+ // LEAF QUARANTINE: genuine subprocess I/O. Spawns `bun install` in the new
33
+ // project root; wrapped in `Effect.tryPromise` by the handler.
34
+ //
35
+ // A timeout is mandatory: `bun install` retries network fetches with backoff
36
+ // when the registry is unreachable, so an offline scaffold would otherwise sit
37
+ // on a silent (stdio: 'ignore') `… Installing dependencies` line forever. On
38
+ // timeout we kill the child and reject; the handler then surfaces the graceful
39
+ // "retry with bun install" hint instead of hanging.
40
+ const INSTALL_TIMEOUT_MS = 180_000;
188
41
  async function runInstall(root) {
42
+ try {
43
+ await runInstallWith('bun', root);
44
+ }
45
+ catch (error) {
46
+ // The CLI runs fine under plain node; a node-only machine has no `bun` on
47
+ // PATH (spawn ENOENT). Fall back to npm so `npm i -g @hs-x/cli && hs-x init`
48
+ // works without installing Bun first.
49
+ if (error.code === 'ENOENT') {
50
+ await runInstallWith('npm', root);
51
+ return;
52
+ }
53
+ throw error;
54
+ }
55
+ }
56
+ async function runInstallWith(tool, root) {
189
57
  await new Promise((resolvePromise, rejectPromise) => {
190
- const child = spawn('bun', ['install'], { cwd: root, stdio: 'ignore' });
191
- child.on('error', rejectPromise);
58
+ const child = spawn(tool, ['install'], { cwd: root, stdio: 'ignore' });
59
+ const timer = setTimeout(() => {
60
+ child.kill('SIGKILL');
61
+ rejectPromise(new Error(`${tool} install timed out after ${INSTALL_TIMEOUT_MS / 1000}s (registry unreachable?)`));
62
+ }, INSTALL_TIMEOUT_MS);
63
+ timer.unref?.();
64
+ child.on('error', (error) => {
65
+ clearTimeout(timer);
66
+ rejectPromise(error);
67
+ });
192
68
  child.on('exit', (code) => {
69
+ clearTimeout(timer);
193
70
  if (code === 0)
194
71
  resolvePromise();
195
72
  else
196
- rejectPromise(new Error(`bun install exited with code ${code}`));
73
+ rejectPromise(new Error(`${tool} install exited with code ${code}`));
197
74
  });
198
75
  });
199
76
  }
200
- function cancelledResult(argv, command) {
201
- const reporter = createReporter({ command, argv });
202
- reporter.info('Cancelled.');
203
- return { exitCode: 130 };
204
- }
205
- function isFlagValue(value) {
206
- return value !== undefined && value.length > 0 && !value.startsWith('-');
207
- }
208
- function resolveFlag(argv, flag) {
209
- const index = argv.indexOf(flag);
210
- if (index !== -1) {
211
- const next = argv[index + 1];
212
- if (isFlagValue(next))
213
- return next;
214
- }
215
- const prefix = `${flag}=`;
216
- const found = argv.find((arg) => arg.startsWith(prefix));
217
- if (!found)
218
- return undefined;
219
- const value = found.slice(prefix.length);
220
- return value.length > 0 ? value : undefined;
221
- }
222
- // ─── Native @effect/cli command (Phase 4) ──────────────────────────────────
223
- // `init` is QUARANTINED: scaffolding binds to an imperative, prompt-driven body
224
- // (`promptText`/`promptSelect`/`promptConfirm`, `mkdir`/`writeFile`, and a
225
- // `bun install` child process) whose internal purity is out of scope for this
226
- // pass. The native command reconstructs the legacy argv from its typed options /
227
- // positional and delegates to the existing `initProject` Promise via
228
- // `Effect.tryPromise`; only the command boundary is Effect. A non-zero
229
- // `CliResult.exitCode` is surfaced as a silent `exitWith(code)` (output already
230
- // rendered by the legacy body) so `runHandler` does not re-render.
77
+ // ─── Native @effect/cli command ────────────────────────────────────────────
78
+ // `init` is de-quarantined: the scaffolding logic runs directly on Effect
79
+ // services (`FileSystem`/`Path` for all fs, `Reporter` for all output, `Cwd`
80
+ // for the working dir). Only two genuine leaf I/O seams remain wrapped in
81
+ // `Effect.tryPromise`/`Effect.promise`: the interactive TTY prompts
82
+ // (`promptText`/`promptSelect`/`promptConfirm`, non-json TTY only) and the
83
+ // `bun install` subprocess (`runInstall`, only when the user opts in).
231
84
  //
232
85
  // `init` and its `create` alias are built from one factory; only the command
233
- // name differs (both reconstruct the same `init`-prefixed argv the legacy body
234
- // parses).
86
+ // name differs.
235
87
  const nameArg = Args.text({ name: 'name' }).pipe(Args.optional);
236
88
  // Legacy init accepts the project name as EITHER a positional or `--name`.
237
89
  const nameOption = Options.text('name').pipe(Options.optional);
@@ -239,9 +91,376 @@ const templateOption = Options.text('template').pipe(Options.optional);
239
91
  const typeOption = Options.text('type').pipe(Options.optional);
240
92
  const appNameOption = Options.text('app-name').pipe(Options.optional);
241
93
  const displayNameOption = Options.text('display-name').pipe(Options.optional);
94
+ const distributionOption = Options.text('distribution').pipe(Options.optional);
95
+ const authOption = Options.text('auth').pipe(Options.optional);
96
+ // Two explicit flags give the tri-state (@effect/cli doesn't auto-negate):
97
+ // `--ui-extension` forces on, `--no-ui-extension` forces off, neither prompts.
98
+ const uiExtensionOption = Options.boolean('ui-extension').pipe(Options.withDefault(false));
99
+ const noUiExtensionOption = Options.boolean('no-ui-extension').pipe(Options.withDefault(false));
242
100
  const yesOption = Options.boolean('yes').pipe(Options.withAlias('y'), Options.withDefault(false));
243
101
  const noInstallOption = Options.boolean('no-install').pipe(Options.withDefault(false));
244
102
  const forceOption = Options.boolean('force').pipe(Options.withDefault(false));
103
+ /** Emit a cancelled result (interactive prompt aborted). */
104
+ function cancelled() {
105
+ return Effect.gen(function* () {
106
+ const reporter = yield* Reporter;
107
+ yield* reporter.info('Cancelled.');
108
+ return yield* Effect.fail(exitWith(130));
109
+ });
110
+ }
111
+ function initBody(opts) {
112
+ return Effect.gen(function* () {
113
+ const fs = yield* FileSystem.FileSystem;
114
+ const pathSvc = yield* Path.Path;
115
+ const reporter = yield* Reporter;
116
+ // `@effect/cli` greedily consumes the NEXT token as a text option's value,
117
+ // so `--name --json` yields nameFlag="--json". Treat a flag-like value as a
118
+ // missing name and restore the swallowed `--json` so json mode isn't lost.
119
+ let json = opts.json;
120
+ let flagName;
121
+ if (Option.isSome(opts.nameFlag)) {
122
+ const value = opts.nameFlag.value;
123
+ if (value.startsWith('-')) {
124
+ if (value === '--json')
125
+ json = true;
126
+ }
127
+ else {
128
+ flagName = value;
129
+ }
130
+ }
131
+ const positional = Option.getOrUndefined(opts.name);
132
+ const flagTemplateRaw = Option.getOrUndefined(opts.template) ?? Option.getOrUndefined(opts.type);
133
+ const flagTemplate = resolveTemplateType(flagTemplateRaw);
134
+ if (flagTemplateRaw && !flagTemplate) {
135
+ // Surface the same flat `{ok:false,error}` / `Error: …` shape the legacy
136
+ // direct dispatcher produced for a thrown error — rendered here (not via
137
+ // the registry error block) so the exit code path stays byte-identical.
138
+ const message = `Unknown app type: ${flagTemplateRaw}. Use workflow-action, sync-source, or empty.`;
139
+ if (json) {
140
+ yield* reporter.raw(`${JSON.stringify({ ok: false, error: message }, null, 2)}\n`);
141
+ }
142
+ else {
143
+ yield* reporter.rawErr(`Error: ${message}\n`);
144
+ }
145
+ return yield* Effect.fail(exitWith(1));
146
+ }
147
+ const flagAppName = Option.getOrUndefined(opts.appName) ?? Option.getOrUndefined(opts.displayName);
148
+ const flagYes = opts.yes;
149
+ const flagSkipInstall = opts.noInstall;
150
+ const force = opts.force;
151
+ const interactive = !json && isInteractive();
152
+ // `--cwd` overrides the injected process working dir.
153
+ let workingCwd = yield* Option.match(opts.cwd, {
154
+ onNone: () => Cwd,
155
+ onSome: (dir) => Effect.succeed(dir),
156
+ });
157
+ if (positional &&
158
+ (positional.includes('/') || positional.startsWith('.') || positional.startsWith('~'))) {
159
+ const expanded = positional.startsWith('~')
160
+ ? pathSvc.join(process.env.HOME ?? '', positional.slice(1))
161
+ : positional;
162
+ const abs = pathSvc.resolve(workingCwd, expanded);
163
+ workingCwd = pathSvc.dirname(abs);
164
+ }
165
+ let bannerShown = false;
166
+ if (interactive) {
167
+ yield* reporter.banner;
168
+ bannerShown = true;
169
+ }
170
+ const rawName = positional ?? flagName;
171
+ let name;
172
+ if (rawName) {
173
+ name = pathSvc.basename(rawName.startsWith('~')
174
+ ? pathSvc.join(process.env.HOME ?? '', rawName.slice(1))
175
+ : pathSvc.resolve(workingCwd, rawName));
176
+ const validationError = validateProjectName(name);
177
+ if (validationError) {
178
+ const code = 'HSX_E_INPUT_BAD_NAME';
179
+ yield* reporter.error(code, `Invalid project name: ${name}`, {
180
+ hint: validationError,
181
+ docs_url: `https://hs-x.dev/errors/${code}`,
182
+ });
183
+ yield* reporter.done(undefined, 10);
184
+ return yield* Effect.fail(exitWith(10));
185
+ }
186
+ }
187
+ // Enum flags are validated up front (the wizard never sees a bad value).
188
+ if (Option.isSome(opts.distribution) && !resolveEnumFlag(opts.distribution, ['private', 'marketplace'])) {
189
+ const message = `Unknown distribution: ${opts.distribution.value}. Use private or marketplace.`;
190
+ if (json)
191
+ yield* reporter.raw(`${JSON.stringify({ ok: false, error: message }, null, 2)}\n`);
192
+ else
193
+ yield* reporter.rawErr(`Error: ${message}\n`);
194
+ return yield* Effect.fail(exitWith(1));
195
+ }
196
+ if (Option.isSome(opts.auth) && !resolveEnumFlag(opts.auth, ['oauth', 'static'])) {
197
+ const message = `Unknown auth: ${opts.auth.value}. Use oauth or static.`;
198
+ if (json)
199
+ yield* reporter.raw(`${JSON.stringify({ ok: false, error: message }, null, 2)}\n`);
200
+ else
201
+ yield* reporter.rawErr(`Error: ${message}\n`);
202
+ return yield* Effect.fail(exitWith(1));
203
+ }
204
+ const flagDistribution = resolveEnumFlag(opts.distribution, ['private', 'marketplace']);
205
+ const flagAuth = resolveEnumFlag(opts.auth, ['oauth', 'static']);
206
+ // Wizard state. Flags pre-seed answers (those steps are skipped); --yes or
207
+ // a non-interactive run accepts the defaults below as-is.
208
+ const draft = {
209
+ name: name ?? 'hsx-app',
210
+ templateId: flagTemplate ?? 'deals-tagger',
211
+ uiExtension: opts.uiExtension && !opts.noUiExtension,
212
+ distribution: flagDistribution ?? 'private',
213
+ auth: flagAuth ?? 'oauth',
214
+ appName: flagAppName,
215
+ shouldInstall: !flagSkipInstall,
216
+ };
217
+ if (interactive && !flagYes) {
218
+ const steps = [
219
+ {
220
+ skip: () => name !== undefined,
221
+ run: async () => {
222
+ const answer = await promptText({
223
+ message: 'Project name',
224
+ default: draft.name,
225
+ validate: validateProjectName,
226
+ allowBack: true,
227
+ });
228
+ if (answer === undefined)
229
+ return 'cancel';
230
+ if (answer === BACK)
231
+ return 'back';
232
+ draft.name = answer;
233
+ return 'next';
234
+ },
235
+ },
236
+ {
237
+ skip: () => flagTemplate !== undefined,
238
+ run: async () => {
239
+ const picked = await promptSelect({
240
+ message: 'What are you building?',
241
+ default: draft.templateId,
242
+ options: TEMPLATES.map((t) => ({
243
+ value: t.id,
244
+ label: t.label,
245
+ description: t.description,
246
+ })),
247
+ allowBack: true,
248
+ });
249
+ if (picked === undefined)
250
+ return 'cancel';
251
+ if (picked === BACK)
252
+ return 'back';
253
+ draft.templateId = picked;
254
+ return 'next';
255
+ },
256
+ },
257
+ {
258
+ // UI extension (app card): --ui-extension → on; --no-ui-extension → off.
259
+ skip: () => opts.uiExtension || opts.noUiExtension,
260
+ run: async () => {
261
+ const confirmed = await promptConfirm({
262
+ message: 'Add a UI extension (app card)?',
263
+ default: draft.uiExtension,
264
+ allowBack: true,
265
+ });
266
+ if (confirmed === undefined)
267
+ return 'cancel';
268
+ if (confirmed === BACK)
269
+ return 'back';
270
+ draft.uiExtension = confirmed;
271
+ return 'next';
272
+ },
273
+ },
274
+ {
275
+ skip: () => flagDistribution !== undefined,
276
+ run: async () => {
277
+ const picked = await promptSelect({
278
+ message: 'How will this app be distributed?',
279
+ default: draft.distribution,
280
+ options: [
281
+ { value: 'private', label: 'Private', description: 'One account; not listed publicly' },
282
+ {
283
+ value: 'marketplace',
284
+ label: 'Marketplace',
285
+ description: 'Listed on the HubSpot App Marketplace',
286
+ },
287
+ ],
288
+ allowBack: true,
289
+ });
290
+ if (picked === undefined)
291
+ return 'cancel';
292
+ if (picked === BACK)
293
+ return 'back';
294
+ draft.distribution = picked;
295
+ return 'next';
296
+ },
297
+ },
298
+ {
299
+ skip: () => flagAuth !== undefined,
300
+ run: async () => {
301
+ const picked = await promptSelect({
302
+ message: 'How does the app authenticate?',
303
+ default: draft.auth,
304
+ options: [
305
+ { value: 'oauth', label: 'OAuth', description: 'Install-time OAuth; per-portal tokens' },
306
+ { value: 'static', label: 'Static', description: 'A single static access token' },
307
+ ],
308
+ allowBack: true,
309
+ });
310
+ if (picked === undefined)
311
+ return 'cancel';
312
+ if (picked === BACK)
313
+ return 'back';
314
+ draft.auth = picked;
315
+ return 'next';
316
+ },
317
+ },
318
+ {
319
+ skip: () => flagAppName !== undefined,
320
+ run: async () => {
321
+ const answer = await promptText({
322
+ message: 'HubSpot app name',
323
+ default: draft.appName ?? toDisplayName(draft.name),
324
+ validate(value) {
325
+ return value.trim().length === 0 ? 'Enter an app name.' : undefined;
326
+ },
327
+ allowBack: true,
328
+ });
329
+ if (answer === undefined)
330
+ return 'cancel';
331
+ if (answer === BACK)
332
+ return 'back';
333
+ draft.appName = answer.trim();
334
+ return 'next';
335
+ },
336
+ },
337
+ {
338
+ skip: () => flagSkipInstall,
339
+ run: async () => {
340
+ const confirmed = await promptConfirm({
341
+ message: 'Install dependencies now?',
342
+ default: draft.shouldInstall,
343
+ allowBack: true,
344
+ });
345
+ if (confirmed === undefined)
346
+ return 'cancel';
347
+ if (confirmed === BACK)
348
+ return 'back';
349
+ draft.shouldInstall = confirmed;
350
+ return 'next';
351
+ },
352
+ },
353
+ ];
354
+ const outcome = yield* Effect.promise(async () => {
355
+ let index = 0;
356
+ while (index < steps.length) {
357
+ const step = steps[index];
358
+ if (step === undefined || step.skip()) {
359
+ index += 1;
360
+ continue;
361
+ }
362
+ const result = await step.run();
363
+ if (result === 'cancel')
364
+ return 'cancelled';
365
+ if (result === 'back') {
366
+ // Walk back to the previous non-skipped step; Esc on the first
367
+ // step just re-asks it.
368
+ let previous = index - 1;
369
+ while (previous >= 0 && (steps[previous]?.skip() ?? true))
370
+ previous -= 1;
371
+ index = Math.max(0, previous);
372
+ continue;
373
+ }
374
+ index += 1;
375
+ }
376
+ return 'done';
377
+ });
378
+ if (outcome === 'cancelled')
379
+ return yield* cancelled();
380
+ }
381
+ name = draft.name;
382
+ const templateId = draft.templateId;
383
+ const uiExtension = draft.uiExtension;
384
+ const distribution = draft.distribution;
385
+ const auth = draft.auth;
386
+ const appName = draft.appName?.trim() || toDisplayName(name);
387
+ const shouldInstall = interactive && !flagYes
388
+ ? !flagSkipInstall && draft.shouldInstall
389
+ : flagYes && !flagSkipInstall;
390
+ const root = pathSvc.resolve(workingCwd, name);
391
+ const configPath = pathSvc.join(root, 'hsx.config.ts');
392
+ const configExists = yield* fs.exists(configPath).pipe(Effect.orElseSucceed(() => false));
393
+ if (configExists && !force) {
394
+ const code = 'HSX_E_INIT_DIR_NOT_EMPTY';
395
+ yield* reporter.error(code, `Project already exists at ${root}.`, {
396
+ hint: `Pick a new directory, delete the existing one (rm -rf ${root}), or pass --force to overwrite.`,
397
+ docs_url: `https://hs-x.dev/errors/${code}`,
398
+ });
399
+ yield* reporter.done(undefined, 10);
400
+ return yield* Effect.fail(exitWith(10));
401
+ }
402
+ if (interactive)
403
+ yield* reporter.header(name);
404
+ else if (!json && !bannerShown) {
405
+ yield* reporter.banner;
406
+ yield* reporter.header(name);
407
+ }
408
+ const template = getTemplate(templateId);
409
+ const files = template.files(name, { appName, distribution, auth, uiExtension });
410
+ yield* fs
411
+ .makeDirectory(pathSvc.join(root, 'src', 'workers'), { recursive: true })
412
+ .pipe(Effect.mapError((cause) => cliError('HSX_E_INTERNAL', `Could not create ${root}: ${String(cause)}`)));
413
+ const scaffoldStep = yield* reporter.step('Scaffolding project');
414
+ for (const file of files) {
415
+ const filePath = pathSvc.join(root, file.path);
416
+ yield* fs
417
+ .makeDirectory(pathSvc.dirname(filePath), { recursive: true })
418
+ .pipe(Effect.mapError((cause) => cliError('HSX_E_INTERNAL', `Could not create directory for ${filePath}: ${String(cause)}`)));
419
+ yield* fs
420
+ .writeFileString(filePath, file.contents, { flag: force ? 'w' : 'wx' })
421
+ .pipe(Effect.mapError((cause) => cliError('HSX_E_INTERNAL', `Could not write ${filePath}: ${String(cause)}`)));
422
+ }
423
+ yield* scaffoldStep.ok(`${files.length} file${files.length === 1 ? '' : 's'} ${root}`);
424
+ if (shouldInstall) {
425
+ const installStep = yield* reporter.step('Installing dependencies');
426
+ const installed = yield* Effect.tryPromise({
427
+ try: () => runInstall(root),
428
+ catch: (error) => (error instanceof Error ? error.message : String(error)),
429
+ }).pipe(Effect.as(true), Effect.catchAll((message) => Effect.gen(function* () {
430
+ yield* installStep.fail(message);
431
+ yield* reporter.warn('HSX_W_INIT_INSTALL_FAILED', `Dependency install failed; you can retry with: cd ${name} && bun install`);
432
+ return false;
433
+ })));
434
+ if (installed)
435
+ yield* installStep.ok();
436
+ }
437
+ const result = {
438
+ ok: true,
439
+ command: 'init',
440
+ project: {
441
+ name,
442
+ appName,
443
+ root,
444
+ template: templateId,
445
+ distribution,
446
+ auth,
447
+ uiExtension,
448
+ installed: shouldInstall,
449
+ },
450
+ };
451
+ if (json) {
452
+ yield* reporter.raw(`${JSON.stringify(result, null, 2)}\n`);
453
+ return;
454
+ }
455
+ yield* reporter.info('');
456
+ yield* reporter.info('Next steps:');
457
+ yield* reporter.info(` cd ${name}`);
458
+ if (!shouldInstall)
459
+ yield* reporter.info(' bun install');
460
+ yield* reporter.info(' hs-x dev');
461
+ yield* reporter.done('Created');
462
+ });
463
+ }
245
464
  function makeInitCmd(commandName) {
246
465
  return Command.make(commandName, {
247
466
  name: nameArg,
@@ -250,54 +469,21 @@ function makeInitCmd(commandName) {
250
469
  type: typeOption,
251
470
  appName: appNameOption,
252
471
  displayName: displayNameOption,
472
+ distribution: distributionOption,
473
+ auth: authOption,
474
+ uiExtension: uiExtensionOption,
475
+ noUiExtension: noUiExtensionOption,
253
476
  yes: yesOption,
254
477
  noInstall: noInstallOption,
255
478
  force: forceOption,
256
479
  json: jsonOption,
257
480
  cwd: cwdOption,
258
- }, (opts) => runHandler(commandName, { json: opts.json }, Effect.gen(function* () {
259
- // Reconstruct the legacy argv the quarantined body parses (it reads
260
- // the positional name + --name/--template/etc off argv).
261
- //
262
- // @effect/cli greedily consumes the NEXT token as a text option's
263
- // value, so `--name --json` yields nameFlag="--json". Treat a
264
- // flag-like value as a missing name and restore the swallowed flag so
265
- // it isn't lost (matches the legacy hardened parser).
266
- let jsonOn = opts.json;
267
- const argv = ['init'];
268
- if (Option.isSome(opts.name))
269
- argv.push(opts.name.value);
270
- if (Option.isSome(opts.nameFlag)) {
271
- const value = opts.nameFlag.value;
272
- if (value.startsWith('-')) {
273
- if (value === '--json')
274
- jsonOn = true;
275
- }
276
- else {
277
- argv.push('--name', value);
278
- }
279
- }
280
- if (Option.isSome(opts.template))
281
- argv.push('--template', opts.template.value);
282
- if (Option.isSome(opts.type))
283
- argv.push('--type', opts.type.value);
284
- if (Option.isSome(opts.appName))
285
- argv.push('--app-name', opts.appName.value);
286
- if (Option.isSome(opts.displayName)) {
287
- argv.push('--display-name', opts.displayName.value);
288
- }
289
- if (opts.yes)
290
- argv.push('--yes');
291
- if (opts.noInstall)
292
- argv.push('--no-install');
293
- if (opts.force)
294
- argv.push('--force');
295
- if (jsonOn)
296
- argv.push('--json');
297
- const cwdOverride = Option.getOrUndefined(opts.cwd);
298
- const cwd = cwdOverride ?? (yield* Cwd);
299
- yield* runQuarantined(jsonOn, () => initProject({ argv, cwd, json: jsonOn }));
300
- })));
481
+ }, (opts) => {
482
+ // Restore the swallowed `--json` for the reporter flag too (see initBody).
483
+ const json = opts.json ||
484
+ (Option.isSome(opts.nameFlag) && opts.nameFlag.value === '--json');
485
+ return runHandler(commandName, { json }, initBody(opts));
486
+ });
301
487
  }
302
488
  export const initCmd = makeInitCmd('init');
303
489
  export const createCmd = makeInitCmd('create');
@@ -311,6 +497,9 @@ Options:
311
497
  --type <type> App type: workflow-action | sync-source | empty
312
498
  --template <id> Starter template id: deals-tagger | empty | sync-source
313
499
  --app-name <name> HubSpot app display name (defaults from project name)
500
+ --distribution <mode> private (default) | marketplace
501
+ --auth <mode> oauth (default) | static
502
+ --ui-extension Scaffold a starter UI-extension app card (--no-ui-extension to skip)
314
503
  --yes, -y Accept defaults (install deps, no prompts)
315
504
  --no-install Skip dependency install
316
505
  --force Overwrite an existing hsx.config.ts at target