@haystackeditor/cli 0.27.0 → 0.27.1

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.
@@ -62,7 +62,8 @@ export function recordedApps(gitRoot, candidates) {
62
62
  return readCaptureConfig(join(gitRoot, dir))?.app === dir;
63
63
  }
64
64
  catch (error) {
65
- throw new Error(`${posix.join(dir, CAPTURE_CONFIG_PATH)}: ${error instanceof Error ? error.message : String(error)}`);
65
+ const message = error instanceof Error ? error.message : String(error);
66
+ throw new Error(dir === '.' ? message : message.replace(CAPTURE_CONFIG_PATH, posix.join(dir, CAPTURE_CONFIG_PATH)));
66
67
  }
67
68
  });
68
69
  }
@@ -21,9 +21,9 @@
21
21
  * stores or reads one), production's environment, the start command, the deploy and the off switches (13c).
22
22
  */
23
23
  import { execFileSync, spawnSync } from 'node:child_process';
24
- import { existsSync, lstatSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
24
+ import { accessSync, constants, existsSync, lstatSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
25
25
  import { createRequire } from 'node:module';
26
- import { dirname, extname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
26
+ import { delimiter, dirname, extname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
27
27
  import { fileURLToPath } from 'node:url';
28
28
  import chalk from 'chalk';
29
29
  import { classifyHttpError, repoApiPath } from '../utils/haystack-api.js';
@@ -103,7 +103,8 @@ function section(json, name) {
103
103
  }
104
104
  const LOCKFILES = [['pnpm-lock.yaml', 'pnpm'], ['package-lock.json', 'npm'], ['yarn.lock', 'yarn'], ['bun.lock', 'bun'], ['bun.lockb', 'bun']];
105
105
  /** The package manager that owns the app's lockfile: the nearest `packageManager` field, else the nearest lockfile, from
106
- * the app up to the repository root. null: no lockfile, so the dependency is written into package.json itself. */
106
+ * the app up to the repository root (`from` says which). null: no lockfile, so the dependency is written into package.json
107
+ * itself. */
107
108
  function packageManager(gitRoot, appDir) {
108
109
  for (let directory = appDir;; directory = dirname(directory)) {
109
110
  const manifest = join(directory, 'package.json');
@@ -111,16 +112,32 @@ function packageManager(gitRoot, appDir) {
111
112
  const declared = readPackage(manifest).json.packageManager;
112
113
  const name = typeof declared === 'string' ? /^(pnpm|npm|yarn|bun)@/.exec(declared)?.[1] : undefined;
113
114
  if (name)
114
- return { name: name, root: directory };
115
+ return { name: name, root: directory, from: `${inside(gitRoot, manifest)}'s packageManager field` };
115
116
  }
116
117
  for (const [lockfile, name] of LOCKFILES) {
117
118
  if (isFile(join(directory, lockfile)))
118
- return { name, root: directory };
119
+ return { name, root: directory, from: inside(gitRoot, join(directory, lockfile)) };
119
120
  }
120
121
  if (directory === gitRoot || dirname(directory) === directory)
121
122
  return null;
122
123
  }
123
124
  }
125
+ /** Whether `command` is an executable on PATH (a package manager init would run, checked before it plans to). */
126
+ function onPath(command) {
127
+ const extensions = process.platform === 'win32' ? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';') : [''];
128
+ for (const directory of (process.env.PATH ?? '').split(delimiter)) {
129
+ if (directory === '')
130
+ continue;
131
+ for (const extension of extensions) {
132
+ try {
133
+ accessSync(join(directory, `${command}${extension}`), constants.X_OK);
134
+ return true;
135
+ }
136
+ catch { /* not in this directory */ }
137
+ }
138
+ }
139
+ return false;
140
+ }
124
141
  /** The first release whose sites record repository-relative paths (what a registered tenant's src_prefix '' relies on)
125
142
  * and that ships the `./next` entry: earlier releases are not compatible with either integration. */
126
143
  const CLI_COMPATIBLE_SINCE = [0, 24, 0];
@@ -216,14 +233,29 @@ function planDependency(gitRoot, appDir, pkg, after, need) {
216
233
  const spec = `${CLI_PACKAGE}@${version}`;
217
234
  const dev = field === 'devDependencies';
218
235
  const workspaceRoot = isFile(join(appDir, 'pnpm-workspace.yaml')) ? ['--workspace-root'] : [];
219
- // Each package manager's add, as it changes no lockfile entry but the CLI's own (lockfile-pin.ts): npm with its tree
220
- // nested under the CLI (hoisting would replace packages the app already locks), pnpm through the merge.
236
+ // Each package manager's add, as it changes no lockfile entry but the CLI's own (lockfile-pin.ts; pnpm through the merge).
221
237
  const argv = manager === null ? null : {
222
238
  pnpm: ['pnpm', 'add', '--save-exact', dev ? '--save-dev' : '--save-prod', ...workspaceRoot, spec],
223
- npm: ['npm', 'install', '--save-exact', dev ? '--save-dev' : '--save-prod', '--install-strategy=nested', spec],
239
+ npm: ['npm', 'install', '--save-exact', dev ? '--save-dev' : '--save-prod', spec],
224
240
  yarn: ['yarn', 'add', '--exact', ...(dev ? ['--dev'] : []), spec],
225
241
  bun: ['bun', 'add', '--exact', ...(dev ? ['--dev'] : []), spec],
226
242
  }[manager.name];
243
+ const appPath = inside(gitRoot, appDir) || '.';
244
+ // npm re-resolves entries the CLI does not own whatever the flags (39 in fosrl/pangolin, with --install-strategy=nested
245
+ // too), so init never rewrites an npm lockfile: the person adds the CLI with npm and sees the lockfile's diff.
246
+ if (manager?.name === 'npm' && argv) {
247
+ return { kind: 'manual', why: `${appPath} uses npm (${manager.from}), whose install also re-resolves lockfile entries the CLI does not own, so init`
248
+ + ` does not run it. Run \`${argv.join(' ')}\` in ${appPath}, check package-lock.json's diff, then run \`haystack init\` again` };
249
+ }
250
+ // A package manager that is not installed is never run (rule 13: telemetry never fails init).
251
+ if (manager && argv && !onPath(manager.name)) {
252
+ const provide = (manager.name === 'pnpm' || manager.name === 'yarn') && onPath('corepack')
253
+ ? `run \`corepack enable ${manager.name}\` (corepack is installed here)`
254
+ : `install it (${manager.name === 'bun' ? 'https://bun.sh' : manager.name === 'yarn' ? 'https://yarnpkg.com/getting-started/install' : 'https://pnpm.io/installation'})`;
255
+ return { kind: 'manual', why: `${appPath} uses ${manager.name} (${manager.from}), which is not installed here, so init did not run it. Either`
256
+ + ` ${provide} and run \`haystack init\` again, or run \`${argv.join(' ')}\` in ${appPath} wherever ${manager.name} is installed and then`
257
+ + ' run `haystack init` again' };
258
+ }
227
259
  const action = promoting ? `Moved from devDependencies to dependencies at ${version}` : `Pinned to ${version}`;
228
260
  const lockfileNote = manager?.name === 'pnpm'
229
261
  ? `, keeping every other entry of pnpm's lockfile as it was (pnpm's own add re-resolves packages the CLI shares with the app; init adds only the CLI's entries, then \`pnpm install --frozen-lockfile\`)`
@@ -247,12 +279,14 @@ function planDependency(gitRoot, appDir, pkg, after, need) {
247
279
  }
248
280
  /** The lockfile a package manager writes, in a format lockfile-pin.ts reads (bun's binary lockb aside). */
249
281
  function textLockfile(manager) {
250
- const name = { pnpm: 'pnpm-lock.yaml', npm: 'package-lock.json', yarn: 'yarn.lock', bun: 'bun.lock' }[manager.name];
282
+ if (manager.name === 'npm')
283
+ throw new Error('init never runs npm to pin the CLI');
284
+ const name = { pnpm: 'pnpm-lock.yaml', yarn: 'yarn.lock', bun: 'bun.lock' }[manager.name];
251
285
  const path = join(manager.root, name);
252
286
  return isFile(path) ? { path, format: manager.name } : null;
253
287
  }
254
288
  /** Runs the add, so that the lockfile gains the CLI's entries and nothing else moves (rule 13); throws (init then restores
255
- * every file) when the package manager moved anything else. */
289
+ * telemetry's files and leaves telemetry manual) when the package manager moved anything else. */
256
290
  function pin(manager, appDir, argv) {
257
291
  const run = (command) => { execFileSync(command[0], command.slice(1), { cwd: appDir, stdio: ['ignore', 2, 2] }); };
258
292
  const lockfile = textLockfile(manager);
@@ -272,8 +306,8 @@ function pin(manager, appDir, argv) {
272
306
  const moved = lockfileDrift(lockfile.format, before, readFileSync(lockfile.path, 'utf8'), importer, CLI_PACKAGE);
273
307
  if (moved.length > 0) {
274
308
  throw new Error(`\`${argv.join(' ')}\` also changed ${moved.length} lockfile ${moved.length === 1 ? 'entry' : 'entries'} the CLI does not own`
275
- + ` (${moved.slice(0, 5).join(', ')}${moved.length > 5 ? ', …' : ''}), so init put every file back. Add ${CLI_PACKAGE} at its exact version`
276
- + ` to ${inside(manager.root, appDir) || '.'} yourself, keeping the lockfile's other entries, then run \`haystack init\` again`);
309
+ + ` (${moved.slice(0, 5).join(', ')}${moved.length > 5 ? ', …' : ''}). Add ${CLI_PACKAGE} at its exact version to`
310
+ + ` ${inside(manager.root, appDir) || '.'} yourself, keeping the lockfile's other entries, then run \`haystack init\` again`);
277
311
  }
278
312
  }
279
313
  function isNode(value) {
@@ -1006,6 +1040,22 @@ export function previewTelemetry(plan, repository, notes) {
1006
1040
  const profile = dbProfile(repository, null, notes);
1007
1041
  return { telemetry: { capture: plan.capture, server: serverPartOf(plan), dbProfileCommand: profile?.command ?? null }, steps };
1008
1042
  }
1043
+ /** A telemetry plan with nothing planned, for a run whose telemetry could not be planned at all (failedTelemetry says why). */
1044
+ export function emptyTelemetryPlan() {
1045
+ return { app: null, changes: [], notices: [], server: { status: 'manual', kind: null, steps: [] }, settings: null,
1046
+ capture: { status: 'manual', steps: [], received: null }, captureRegistration: null };
1047
+ }
1048
+ /** Telemetry that could not be set up (a package manager failed, a file init could not read, Haystack not answering): each
1049
+ * part it would have set up is manual with what happened, every file telemetry changed is back, and the rest of init stands
1050
+ * (telemetry never fails init). */
1051
+ export function failedTelemetry(plan, failure) {
1052
+ const step = `init could not set up telemetry: ${failure.replace(/\.?\s*$/, '')}. It put back every file telemetry changed; the rest of init is`
1053
+ + ' done. Fix that, then run `haystack init` again.';
1054
+ // A part that was already waiting for answers keeps its questions after what happened.
1055
+ const part = (value) => (value.status === 'unsupported' ? value
1056
+ : { status: 'manual', steps: [step, ...(value.status === 'manual' ? value.steps : [])], received: null });
1057
+ return { telemetry: { capture: part(plan.capture), server: part(serverPartOf(plan)), dbProfileCommand: null }, steps: [step] };
1058
+ }
1009
1059
  /** Rule 8(e): the report, and what the person does for telemetry to arrive (the token, production's environment, the start,
1010
1060
  * the deploy) with the off switches (rule 13c). `onboarding` and `notes` feed the data profile. */
1011
1061
  export async function telemetryReport(plan, context) {
@@ -299,6 +299,33 @@ export function planInit(gitRoot) {
299
299
  const notices = [];
300
300
  return { changes: [...planStopHook(gitRoot, notices), ...planAgentNotes(gitRoot, notices)], notices };
301
301
  }
302
+ /** Makes `changes` all or nothing: one that fails (an install that does not end as planned) restores every file they
303
+ * touched (bytes, not text: a lockfile can be binary), so no edit is left referencing what was never installed. */
304
+ function applyAll(gitRoot, changes, say) {
305
+ const before = new Map();
306
+ for (const planned of changes) {
307
+ for (const path of [planned.target, ...(planned.run?.touches ?? [])]) {
308
+ if (!before.has(path))
309
+ before.set(path, existsSync(path) ? readFileSync(path) : null);
310
+ }
311
+ }
312
+ try {
313
+ for (const planned of changes) {
314
+ applyChange(gitRoot, planned);
315
+ say(chalk.green(`✓ ${planned.action === 'create' ? 'Created' : planned.action === 'delete' ? 'Deleted' : 'Updated'} ${planned.path}`));
316
+ }
317
+ }
318
+ catch (error) {
319
+ for (const [path, content] of before) {
320
+ if (content !== null)
321
+ writeFileSync(path, content);
322
+ else if (existsSync(path))
323
+ unlinkSync(path);
324
+ }
325
+ throw error;
326
+ }
327
+ }
328
+ const errorText = (error) => (error instanceof Error ? error.message : String(error));
302
329
  function applyChange(gitRoot, planned) {
303
330
  const inside = relative(gitRoot, planned.target);
304
331
  if (!inside.startsWith('..') && !isAbsolute(inside))
@@ -362,19 +389,35 @@ export async function initCommand(options) {
362
389
  // Read before anything else, so notes that cannot be used stop init with nothing done.
363
390
  const notes = options.notes === undefined ? null : readNotes(options.notes);
364
391
  const plan = planInit(gitRoot);
365
- // Telemetry is part of onboarding (CAPTURE-V1 rule 8): planned on every run; a missing answer stops only it.
392
+ // Telemetry is part of onboarding (CAPTURE-V1 rule 8): planned on every run; a missing answer stops only it. It never fails
393
+ // init: whatever goes wrong with it (a file it cannot read, a package manager that fails, Haystack not answering) puts its
394
+ // own files back and leaves it manual with what happened, and the rest of init goes on.
366
395
  const telemetryModule = await import('./init-telemetry.js');
367
396
  const telemetryOptions = { app: options.app, origins: options.origin ?? [], consent: options.consent, urlRewrites: options.urlRewrites };
368
- let telemetryPlan = await telemetryModule.planTelemetry(gitRoot, repository, telemetryOptions);
397
+ let telemetryFailure = null;
398
+ let telemetryPlan = telemetryModule.emptyTelemetryPlan();
399
+ try {
400
+ telemetryPlan = await telemetryModule.planTelemetry(gitRoot, repository, telemetryOptions);
401
+ }
402
+ catch (error) {
403
+ telemetryFailure = errorText(error);
404
+ }
369
405
  const telemetryFrom = plan.changes.length;
370
406
  plan.changes.push(...telemetryPlan.changes);
371
407
  plan.notices.push(...telemetryPlan.notices);
372
408
  let notesWritten = null;
373
409
  let telemetry = null;
410
+ /** Telemetry did not happen: its changes leave the plan (none of them was made), and the report says why. */
411
+ const telemetryFailed = (error) => {
412
+ telemetryFailure = errorText(error);
413
+ plan.changes.splice(telemetryFrom);
414
+ say(chalk.yellow(`Telemetry was not set up: ${telemetryFailure} The rest of init goes on; the telemetry report says what to do.`));
415
+ };
374
416
  // Before the apply (a preview, or no login), the report is the preview: what telemetry will do, `received` unchecked (null).
375
- const report = () => telemetry ?? telemetryModule.previewTelemetry(telemetryPlan, repository, notes);
417
+ const report = () => (telemetryFailure !== null ? telemetryModule.failedTelemetry(telemetryPlan, telemetryFailure)
418
+ : telemetry ?? telemetryModule.previewTelemetry(telemetryPlan, repository, notes));
376
419
  const showPreview = () => {
377
- const preview = telemetryModule.previewTelemetry(telemetryPlan, repository, notes);
420
+ const preview = report();
378
421
  for (const line of telemetryModule.formatTelemetry(preview.telemetry, preview.steps))
379
422
  say(line);
380
423
  say('');
@@ -424,44 +467,48 @@ export async function initCommand(options) {
424
467
  return;
425
468
  }
426
469
  }
427
- // Browser capture's registration (rule 8b), which the preview announced: checked with Haystack now, and when the files
428
- // need a new key the telemetry changes are planned again with it (or without capture's, when it is refused).
470
+ // Init's own changes (the agent instructions, the Stop hook) first, all or nothing: a failure there is init's.
471
+ try {
472
+ applyAll(gitRoot, plan.changes.slice(0, telemetryFrom), say);
473
+ }
474
+ catch (error) {
475
+ throw new Error(`${errorText(error)} Every file init changed was restored; nothing was changed.`);
476
+ }
477
+ // Then telemetry, all or nothing on its own. Browser capture's registration (rule 8b), which the preview announced, is
478
+ // checked with Haystack first, and when the files need a new key the telemetry changes are planned again with it (or
479
+ // without capture's, when it is refused).
429
480
  const approved = options.yes === true || plan.changes.length > 0;
430
- const capture = telemetryPlan.captureRegistration
431
- ? await telemetryModule.prepareCapture(telemetryPlan.captureRegistration, token, say, approved) : null;
432
- if (capture?.replan) {
433
- telemetryPlan = await telemetryModule.planTelemetry(gitRoot, repository, { ...telemetryOptions, registration: capture.replan });
434
- plan.changes.splice(telemetryFrom, plan.changes.length - telemetryFrom, ...telemetryPlan.changes);
435
- }
436
- // All or nothing: a change that fails (an install that does not end as planned) restores every file the changes
437
- // touched, so no edit is left referencing what was never installed.
438
- // Bytes, not text: a lockfile can be binary (bun.lockb).
439
- const before = new Map();
440
- for (const planned of plan.changes) {
441
- for (const path of [planned.target, ...(planned.run?.touches ?? [])]) {
442
- if (!before.has(path))
443
- before.set(path, existsSync(path) ? readFileSync(path) : null);
481
+ let capture = null;
482
+ if (telemetryFailure === null) {
483
+ try {
484
+ capture = telemetryPlan.captureRegistration
485
+ ? await telemetryModule.prepareCapture(telemetryPlan.captureRegistration, token, say, approved) : null;
486
+ if (capture?.replan) {
487
+ telemetryPlan = await telemetryModule.planTelemetry(gitRoot, repository, { ...telemetryOptions, registration: capture.replan });
488
+ plan.changes.splice(telemetryFrom, plan.changes.length - telemetryFrom, ...telemetryPlan.changes);
489
+ }
490
+ applyAll(gitRoot, plan.changes.slice(telemetryFrom), say);
444
491
  }
445
- }
446
- try {
447
- for (const planned of plan.changes) {
448
- applyChange(gitRoot, planned);
449
- say(chalk.green(`✓ ${planned.action === 'create' ? 'Created' : planned.action === 'delete' ? 'Deleted' : 'Updated'} ${planned.path}`));
492
+ catch (error) {
493
+ try {
494
+ await capture?.rollback(say);
495
+ }
496
+ catch (rollbackError) {
497
+ say(chalk.yellow(`Capture's registration could not be put back: ${errorText(rollbackError)}`));
498
+ }
499
+ telemetryFailed(error);
450
500
  }
451
501
  }
452
- catch (error) {
453
- for (const [path, content] of before) {
454
- if (content !== null)
455
- writeFileSync(path, content);
456
- else if (existsSync(path))
457
- unlinkSync(path);
502
+ if (telemetryFailure === null && capture) {
503
+ try {
504
+ const captureFollowUp = await capture.finish(say);
505
+ if (captureFollowUp)
506
+ telemetryPlan.capture = telemetryModule.withCaptureStep(telemetryPlan.capture, captureFollowUp);
507
+ }
508
+ catch (error) {
509
+ telemetryPlan.capture = telemetryModule.withCaptureStep(telemetryPlan.capture, `Run \`haystack init\` again: Haystack did not take the app's origins (${errorText(error)}).`);
458
510
  }
459
- await capture?.rollback(say);
460
- throw new Error(`${error instanceof Error ? error.message : String(error)} Every file init changed was restored; nothing was changed.`);
461
511
  }
462
- const captureFollowUp = await capture?.finish(say);
463
- if (captureFollowUp)
464
- telemetryPlan.capture = telemetryModule.withCaptureStep(telemetryPlan.capture, captureFollowUp);
465
512
  let onboarding;
466
513
  try {
467
514
  // The notes first: onboarding plans from the facts it starts with, and new notes are new facts (a new onboarding).
@@ -485,8 +532,18 @@ export async function initCommand(options) {
485
532
  reportOnboardingState(onboarding);
486
533
  say(formatOnboarding(onboarding));
487
534
  say('');
488
- telemetry = await telemetryModule.telemetryReport(telemetryPlan, { repository, token, onboarding, notes });
489
- for (const line of telemetryModule.formatTelemetry(telemetry.telemetry, telemetry.steps))
535
+ if (telemetryFailure === null) {
536
+ try {
537
+ telemetry = await telemetryModule.telemetryReport(telemetryPlan, { repository, token, onboarding, notes });
538
+ }
539
+ catch (error) {
540
+ // What Haystack holds could not be read: the report as previewed (`received` unchecked), and why.
541
+ const preview = telemetryModule.previewTelemetry(telemetryPlan, repository, notes);
542
+ telemetry = { telemetry: preview.telemetry, steps: [...preview.steps, `Run \`haystack init --json\` again to read what telemetry Haystack has received (it could not: ${errorText(error)}).`] };
543
+ }
544
+ }
545
+ const shown = report();
546
+ for (const line of telemetryModule.formatTelemetry(shown.telemetry, shown.steps))
490
547
  say(line);
491
548
  say('');
492
549
  if (onboarding.state === 'ready')
@@ -4,13 +4,14 @@
4
4
  * locks older versions of packages the CLI also uses):
5
5
  * - yarn 1, yarn 4 and bun add only the new descriptors and entries; nothing else moves.
6
6
  * - npm hoists the new tree and replaces hoisted packages the app already locks (ws, yaml, zod moved to the CLI's
7
- * versions); with `--install-strategy=nested` it places the new tree under the dependency and moves nothing.
7
+ * versions), and re-resolves other entries even with `--install-strategy=nested` (39 in fosrl/pangolin): init never
8
+ * runs it (init-telemetry.ts leaves the npm install to the person, who sees the lockfile's diff).
8
9
  * - pnpm re-resolves the dependencies of every package the new tree shares with the old one to the newest version in the
9
10
  * graph (47 entries in Rallly, from @smithy/* to express), and no flag stops it. So init lets pnpm resolve the new
10
11
  * dependency (`--lockfile-only`), keeps every entry the lockfile had, adds the entries only the new dependency
11
12
  * reaches (mergePnpmLockfile), and installs exactly that (`pnpm install --frozen-lockfile`, which also proves it).
12
- * lockfileDrift checks the result for every format init can read, so a package manager that moves anything else fails the
13
- * change (and init restores every file).
13
+ * lockfileDrift checks the result for every format init runs, so a package manager that moves anything else fails the
14
+ * change (and init restores telemetry's files, leaving telemetry manual).
14
15
  */
15
16
  function unquote(text) {
16
17
  if (text.startsWith('\'') && text.endsWith('\''))
@@ -271,11 +272,6 @@ function entriesOf(format, text, importer) {
271
272
  }
272
273
  }
273
274
  }
274
- else if (format === 'npm') {
275
- const packages = JSON.parse(text).packages ?? {};
276
- for (const [key, value] of Object.entries(packages))
277
- out.set(key === (importer === '.' ? '' : importer) ? IMPORTER : key, JSON.stringify(value));
278
- }
279
275
  else if (format === 'yarn') {
280
276
  for (const block of text.split(/\n\s*\n/)) {
281
277
  const lines = block.split('\n').filter(line => line.trim() !== '' && !line.startsWith('#'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.27.0",
3
+ "version": "0.27.1",
4
4
  "description": "haystack verify: run your app with and without a change, and see what the change broke",
5
5
  "type": "module",
6
6
  "bin": {