@fission-ai/openspec 1.13.0 → 1.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +16 -4
  2. package/dist/commands/change.js +12 -1
  3. package/dist/commands/config.d.ts +9 -0
  4. package/dist/commands/config.js +161 -22
  5. package/dist/commands/feedback.js +6 -4
  6. package/dist/commands/schema.js +30 -9
  7. package/dist/commands/store.js +5 -2
  8. package/dist/commands/validate.d.ts +7 -0
  9. package/dist/commands/validate.js +63 -1
  10. package/dist/commands/workflow/instructions.js +69 -22
  11. package/dist/commands/workflow/new-change.js +20 -0
  12. package/dist/commands/workflow/shared.d.ts +5 -0
  13. package/dist/commands/workflow/shared.js +8 -0
  14. package/dist/commands/workflow/status.d.ts +5 -1
  15. package/dist/commands/workflow/status.js +42 -5
  16. package/dist/core/archive.js +260 -77
  17. package/dist/core/artifact-graph/outputs.d.ts +1 -1
  18. package/dist/core/artifact-graph/outputs.js +38 -8
  19. package/dist/core/artifact-graph/schema.d.ts +14 -0
  20. package/dist/core/artifact-graph/schema.js +52 -0
  21. package/dist/core/artifact-graph/types.js +12 -1
  22. package/dist/core/change-status-policy.d.ts +14 -0
  23. package/dist/core/change-status-policy.js +14 -6
  24. package/dist/core/command-generation/adapters/continue.js +4 -0
  25. package/dist/core/command-generation/adapters/kilocode.d.ts +1 -1
  26. package/dist/core/command-generation/adapters/kilocode.js +2 -2
  27. package/dist/core/command-generation/generator.js +2 -0
  28. package/dist/core/completion-tip.js +7 -7
  29. package/dist/core/completions/installers/bash-installer.js +15 -7
  30. package/dist/core/completions/installers/shell-quote.d.ts +11 -0
  31. package/dist/core/completions/installers/shell-quote.js +13 -0
  32. package/dist/core/completions/installers/zsh-installer.js +3 -2
  33. package/dist/core/config-schema.d.ts +1 -1
  34. package/dist/core/config.d.ts +16 -0
  35. package/dist/core/config.js +27 -1
  36. package/dist/core/global-config.d.ts +30 -2
  37. package/dist/core/global-config.js +57 -3
  38. package/dist/core/init.js +22 -6
  39. package/dist/core/legacy-cleanup.d.ts +5 -1
  40. package/dist/core/legacy-cleanup.js +207 -14
  41. package/dist/core/list.js +55 -11
  42. package/dist/core/migration.js +6 -1
  43. package/dist/core/parsers/change-parser.d.ts +12 -1
  44. package/dist/core/parsers/change-parser.js +85 -87
  45. package/dist/core/parsers/markdown-parser.js +4 -3
  46. package/dist/core/parsers/requirement-blocks.d.ts +78 -6
  47. package/dist/core/parsers/requirement-blocks.js +155 -38
  48. package/dist/core/parsers/requirement-text.d.ts +13 -2
  49. package/dist/core/parsers/requirement-text.js +34 -7
  50. package/dist/core/parsers/spec-structure.js +4 -1
  51. package/dist/core/project-config.d.ts +9 -1
  52. package/dist/core/project-config.js +11 -2
  53. package/dist/core/references.d.ts +20 -0
  54. package/dist/core/references.js +56 -0
  55. package/dist/core/shared/skill-content-equivalence.js +5 -1
  56. package/dist/core/shared/skill-generation.js +36 -8
  57. package/dist/core/shared/tool-detection.js +9 -3
  58. package/dist/core/specs-apply.d.ts +20 -0
  59. package/dist/core/specs-apply.js +101 -17
  60. package/dist/core/store/git.d.ts +33 -0
  61. package/dist/core/store/git.js +60 -6
  62. package/dist/core/store/operations.d.ts +1 -1
  63. package/dist/core/store/operations.js +32 -10
  64. package/dist/core/store/registry.d.ts +2 -1
  65. package/dist/core/store/registry.js +5 -1
  66. package/dist/core/templates/optional-workflow.d.ts +69 -0
  67. package/dist/core/templates/optional-workflow.js +140 -0
  68. package/dist/core/templates/workflows/apply-change.js +18 -3
  69. package/dist/core/templates/workflows/archive-change.js +70 -21
  70. package/dist/core/templates/workflows/bulk-archive-change.js +85 -21
  71. package/dist/core/templates/workflows/continue-change.js +14 -4
  72. package/dist/core/templates/workflows/explore.js +34 -16
  73. package/dist/core/templates/workflows/ff-change.js +21 -5
  74. package/dist/core/templates/workflows/new-change.js +15 -3
  75. package/dist/core/templates/workflows/onboard.js +76 -41
  76. package/dist/core/templates/workflows/project-root.d.ts +29 -0
  77. package/dist/core/templates/workflows/project-root.js +38 -0
  78. package/dist/core/templates/workflows/propose.js +22 -5
  79. package/dist/core/templates/workflows/sync-specs.js +40 -1
  80. package/dist/core/templates/workflows/update-change.js +66 -27
  81. package/dist/core/templates/workflows/verify-change.js +121 -49
  82. package/dist/core/update.d.ts +12 -0
  83. package/dist/core/update.js +78 -10
  84. package/dist/core/validation/purpose-placeholder.js +24 -9
  85. package/dist/core/validation/task-checkboxes.d.ts +34 -0
  86. package/dist/core/validation/task-checkboxes.js +181 -0
  87. package/dist/core/validation/validator.d.ts +53 -1
  88. package/dist/core/validation/validator.js +160 -45
  89. package/dist/core/version-check.d.ts +14 -7
  90. package/dist/core/version-check.js +63 -17
  91. package/dist/core/worksets.js +6 -4
  92. package/dist/prompts/searchable-multi-select.d.ts +7 -0
  93. package/dist/prompts/searchable-multi-select.js +35 -6
  94. package/dist/telemetry/config.js +14 -2
  95. package/dist/telemetry/index.d.ts +4 -3
  96. package/dist/telemetry/index.js +22 -10
  97. package/dist/telemetry/opt-out.d.ts +17 -0
  98. package/dist/telemetry/opt-out.js +27 -0
  99. package/dist/utils/file-system.js +20 -3
  100. package/dist/utils/line-endings.d.ts +34 -0
  101. package/dist/utils/line-endings.js +46 -0
  102. package/dist/utils/nested-change.d.ts +35 -0
  103. package/dist/utils/nested-change.js +186 -0
  104. package/dist/utils/spec-discovery.d.ts +20 -0
  105. package/dist/utils/spec-discovery.js +59 -0
  106. package/dist/utils/task-progress.d.ts +1 -1
  107. package/dist/utils/task-progress.js +38 -6
  108. package/package.json +5 -5
  109. package/schemas/spec-driven/schema.yaml +18 -3
  110. package/schemas/spec-driven/templates/design.md +2 -0
  111. package/schemas/spec-driven/templates/proposal.md +2 -0
  112. package/schemas/spec-driven/templates/spec.md +2 -0
  113. package/schemas/spec-driven/templates/tasks.md +2 -0
@@ -5,7 +5,8 @@ import path from 'path';
5
5
  import { createRequire } from 'module';
6
6
  import chalk from 'chalk';
7
7
  import { isCiEnvironment } from '../utils/ci.js';
8
- import { getGlobalConfig } from './global-config.js';
8
+ import { isTelemetryOptedOutByEnv } from '../telemetry/opt-out.js';
9
+ import { getGlobalConfig, isGlobalConfigUnreadable } from './global-config.js';
9
10
  const require = createRequire(import.meta.url);
10
11
  const { name: PACKAGE_NAME, version: OPENSPEC_VERSION } = require('../../package.json');
11
12
  const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
@@ -29,9 +30,7 @@ const SAFE_VERSION = /^\d{1,10}\.\d{1,10}\.\d{1,10}(?:-[0-9A-Za-z.-]{1,64})?(?:\
29
30
  function isCheckEnabled() {
30
31
  if (process.env.OPENSPEC_NO_UPDATE_CHECK !== undefined)
31
32
  return false;
32
- if (process.env.DO_NOT_TRACK === '1')
33
- return false;
34
- if (process.env.OPENSPEC_TELEMETRY === '0')
33
+ if (isTelemetryOptedOutByEnv())
35
34
  return false;
36
35
  if (isCiEnvironment())
37
36
  return false;
@@ -40,21 +39,53 @@ function isCheckEnabled() {
40
39
  // Same config opt-out as telemetry (env remains the hard override above).
41
40
  if (getGlobalConfig().telemetry?.enabled === false)
42
41
  return false;
42
+ // A config that cannot be parsed may be hiding that opt-out.
43
+ if (isGlobalConfigUnreadable())
44
+ return false;
43
45
  return true;
44
46
  }
45
47
  /**
46
- * The registry to ask: only the environment variable npm exports (under
47
- * `npm run`, or an explicit export). Deliberately not a `registry=` line from
48
- * any .npmrc — letting file contents choose the destination of an outbound
49
- * request is a flow worth avoiding for a convenience this small, and a project
50
- * file would travel with a cloned repository. Anyone on a private mirror can
51
- * export `npm_config_registry`, or turn the check off entirely.
48
+ * The configured registry, if npm exported one. Not trustworthy: npm exports
49
+ * every config source it read, including a `registry=` line in a
50
+ * repository-local .npmrc that travels with a clone.
52
51
  */
53
- export function registryUrl() {
52
+ function configuredRegistry() {
54
53
  const configured = process.env.npm_config_registry?.trim();
55
- const base = configured && /^https?:\/\//i.test(configured) ? configured : DEFAULT_REGISTRY;
54
+ return configured ? configured : undefined;
55
+ }
56
+ /**
57
+ * The registry to ask. A configured registry is honored only over TLS: a
58
+ * cleartext hop would aim the request at a link-local or internal address and
59
+ * let anyone on the path choose the "newer version" answer.
60
+ *
61
+ * A rejected registry disables the check rather than falling back to public
62
+ * npm. Falling back would send a request an organization on an internal mirror
63
+ * deliberately avoided, and would then report a version resolved against a
64
+ * registry the eventual `npm install -g` does not use.
65
+ */
66
+ export function registryUrl() {
67
+ const configured = configuredRegistry();
68
+ if (configured && !/^https:\/\//i.test(configured))
69
+ return null;
70
+ const base = configured ?? DEFAULT_REGISTRY;
56
71
  return `${base.replace(/\/+$/, '')}/${PACKAGE_NAME}/latest`;
57
72
  }
73
+ /**
74
+ * True when the check is talking to the public npm registry. Read from the raw
75
+ * env var, not from registryUrl(), so a registry that was rejected above still
76
+ * disqualifies a self-upgrade instead of looking like the default.
77
+ */
78
+ function isDefaultRegistry() {
79
+ const configured = configuredRegistry();
80
+ if (!configured)
81
+ return true;
82
+ try {
83
+ return new URL(configured).origin === new URL(DEFAULT_REGISTRY).origin;
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ }
58
89
  /**
59
90
  * Compares two prerelease tags per SemVer: dot-separated identifiers compared
60
91
  * one by one, numeric identifiers numerically (so beta.10 > beta.2), numeric
@@ -141,7 +172,12 @@ function fetchLatestVersion() {
141
172
  };
142
173
  let url;
143
174
  try {
144
- url = new URL(registryUrl());
175
+ const resolved = registryUrl();
176
+ if (resolved === null) {
177
+ resolve(null);
178
+ return;
179
+ }
180
+ url = new URL(resolved);
145
181
  }
146
182
  catch {
147
183
  resolve(null);
@@ -169,10 +205,13 @@ function fetchLatestVersion() {
169
205
  redirectsLeft -= 1;
170
206
  try {
171
207
  const next = new URL(location, target);
172
- // Never follow a downgrade to plain http: a MITM on the reply
173
- // would control the "newer version" answer.
174
- const downgrade = target.protocol === 'https:' && next.protocol === 'http:';
175
- if (!downgrade && (next.protocol === 'http:' || next.protocol === 'https:')) {
208
+ // Cross-host is allowed - that is the whole point of following a
209
+ // redirect for a mirror or corporate front-end - but never off
210
+ // TLS: a MITM on a cleartext hop would control the "newer
211
+ // version" answer, and the reply cannot install anything on its
212
+ // own (canSelfUpgrade refuses a non-default registry, and the
213
+ // version is re-validated against a strict pattern).
214
+ if (next.protocol === 'https:') {
176
215
  send(next);
177
216
  return;
178
217
  }
@@ -492,10 +531,17 @@ function loadSpawn() {
492
531
  * that may not be the one on PATH, a project dependency belongs to that
493
532
  * project's package manager, an npx/dlx cache has nothing to upgrade, and a
494
533
  * source checkout is not an install at all.
534
+ *
535
+ * A non-default registry is also disqualifying. `npm install -g` resolves
536
+ * against whatever `npm_config_registry` says, which npm may have read from a
537
+ * cloned repository's .npmrc — so such a registry may still inform the check,
538
+ * but must never be the thing that offers to install a global package.
495
539
  */
496
540
  export function canSelfUpgrade(installDir, projectPath) {
497
541
  if (!installDir)
498
542
  return false;
543
+ if (!isDefaultRegistry())
544
+ return false;
499
545
  if (isEphemeralRunnerInstall(installDir))
500
546
  return false;
501
547
  // Both anchors matter: `openspec update ../other` from a project that owns
@@ -175,7 +175,7 @@ export function worksetNotFoundError(name, state) {
175
175
  });
176
176
  }
177
177
  export function withWorkset(state, workset) {
178
- if (state.worksets[workset.name] !== undefined) {
178
+ if (Object.prototype.hasOwnProperty.call(state.worksets, workset.name)) {
179
179
  throw new StoreError(`Workset '${workset.name}' already exists.`, 'workset_exists', {
180
180
  target: 'workset.name',
181
181
  fix: `Choose another name, or remove it first: openspec workset remove ${workset.name}`,
@@ -193,7 +193,7 @@ export function withWorkset(state, workset) {
193
193
  };
194
194
  }
195
195
  export function withoutWorkset(state, name) {
196
- if (state.worksets[name] === undefined) {
196
+ if (!Object.prototype.hasOwnProperty.call(state.worksets, name)) {
197
197
  throw worksetNotFoundError(name, state);
198
198
  }
199
199
  const remaining = { ...state.worksets };
@@ -226,8 +226,10 @@ export function listWorksets(state) {
226
226
  .sort((a, b) => a.name.localeCompare(b.name));
227
227
  }
228
228
  export function getWorkset(state, name) {
229
- const entry = state.worksets[name];
230
- return entry === undefined ? null : toWorkset(name, entry);
229
+ if (!Object.prototype.hasOwnProperty.call(state.worksets, name)) {
230
+ return null;
231
+ }
232
+ return toWorkset(name, state.worksets[name]);
231
233
  }
232
234
  /**
233
235
  * The generated .code-workspace content: members in saved order with
@@ -1,6 +1,11 @@
1
1
  interface Choice {
2
2
  name: string;
3
3
  value: string;
4
+ /**
5
+ * Extra terms the search box matches, for choices users look for by a word
6
+ * the name does not spell (see #653). Never rendered.
7
+ */
8
+ searchAliases?: string[];
4
9
  description?: string;
5
10
  configured?: boolean;
6
11
  detected?: boolean;
@@ -12,6 +17,8 @@ interface Config {
12
17
  choices: Choice[];
13
18
  pageSize?: number;
14
19
  validate?: (selected: string[]) => boolean | string;
20
+ /** Shown when a search matches nothing, so the list is not a dead end. */
21
+ emptyHint?: string;
15
22
  }
16
23
  /**
17
24
  * A searchable multi-select prompt with visible search box,
@@ -1,4 +1,11 @@
1
1
  import chalk from 'chalk';
2
+ /**
3
+ * True when every character is printable, so the text can go in the search box.
4
+ * `\u007f` is DEL, which sorts above the printable range.
5
+ */
6
+ function isPrintable(text) {
7
+ return text.length > 0 && [...text].every((char) => char >= ' ' && char !== '\u007f');
8
+ }
2
9
  /**
3
10
  * Create the searchable multi-select prompt.
4
11
  * Uses dynamic import to prevent pre-commit hook hangs (see #367).
@@ -6,7 +13,7 @@ import chalk from 'chalk';
6
13
  async function createSearchableMultiSelect() {
7
14
  const { createPrompt, useState, useKeypress, useMemo, usePrefix, isEnterKey, isBackspaceKey, isUpKey, isDownKey, } = await import('@inquirer/core');
8
15
  return createPrompt((config, done) => {
9
- const { message, choices, pageSize = 15, validate } = config;
16
+ const { message, choices, pageSize = 15, validate, emptyHint } = config;
10
17
  const [searchText, setSearchText] = useState('');
11
18
  const [selectedValues, setSelectedValues] = useState(() => choices.filter(c => c.preSelected).map(c => c.value));
12
19
  const [cursor, setCursor] = useState(0);
@@ -17,9 +24,10 @@ async function createSearchableMultiSelect() {
17
24
  const filteredChoices = useMemo(() => {
18
25
  if (!searchText.trim())
19
26
  return choices;
20
- const term = searchText.toLowerCase();
27
+ const term = searchText.trim().toLowerCase();
21
28
  return choices.filter((c) => c.name.toLowerCase().includes(term) ||
22
- c.value.toLowerCase().includes(term));
29
+ c.value.toLowerCase().includes(term) ||
30
+ (c.searchAliases ?? []).some((alias) => alias.toLowerCase().includes(term)));
23
31
  }, [searchText, choices]);
24
32
  const selectedSet = useMemo(() => new Set(selectedValues), [selectedValues]);
25
33
  const choiceMap = useMemo(() => new Map(choices.map((c) => [c.value, c])), [choices]);
@@ -72,9 +80,28 @@ async function createSearchableMultiSelect() {
72
80
  setCursor(Math.min(filteredChoices.length - 1, cursor + 1));
73
81
  return;
74
82
  }
75
- // Character input - handle printable characters
76
- if (key.name && key.name.length === 1 && !key.ctrl) {
77
- setSearchText(searchText + key.name);
83
+ // Character input - handle printable characters.
84
+ // readline reports punctuation (`.`, `-`, `/`) only in `sequence`, leaving
85
+ // `name` undefined, so keying off `name` alone silently dropped every
86
+ // non-alphanumeric character the user typed.
87
+ // `@inquirer/core` types only `name` and `ctrl`; readline emits more.
88
+ const event = key;
89
+ if (event.ctrl || event.meta)
90
+ return;
91
+ // Requiring every character to be printable drops escape sequences
92
+ // (arrows, function keys), tab, escape and delete. readline splits a
93
+ // paste into one key per character, so a pasted space still toggles.
94
+ const typed = typeof event.sequence === 'string' && isPrintable(event.sequence)
95
+ ? event.sequence
96
+ : // Only the sequence may be multi-character: readline `name`s such as
97
+ // 'tab' and 'escape' are printable strings but not typed input.
98
+ typeof event.name === 'string' &&
99
+ event.name.length === 1 &&
100
+ isPrintable(event.name)
101
+ ? event.name
102
+ : undefined;
103
+ if (typed) {
104
+ setSearchText(searchText + typed);
78
105
  setCursor(0);
79
106
  }
80
107
  });
@@ -102,6 +129,8 @@ async function createSearchableMultiSelect() {
102
129
  // List
103
130
  if (filteredChoices.length === 0) {
104
131
  lines.push(chalk.yellow(' No matches'));
132
+ if (emptyHint)
133
+ lines.push(chalk.dim(` ${emptyHint}`));
105
134
  }
106
135
  else {
107
136
  // Calculate pagination
@@ -5,7 +5,7 @@
5
5
  import { promises as fs } from 'fs';
6
6
  import path from 'path';
7
7
  import os from 'os';
8
- import { GLOBAL_CONFIG_DIR_NAME, GLOBAL_CONFIG_FILE_NAME, getGlobalConfigDir, } from '../core/global-config.js';
8
+ import { GLOBAL_CONFIG_DIR_NAME, GLOBAL_CONFIG_FILE_NAME, getGlobalConfigDir, isConfigRootObject, unreadableGlobalConfigMessage, } from '../core/global-config.js';
9
9
  // Constants
10
10
  export const CONFIG_DIR_NAME = GLOBAL_CONFIG_DIR_NAME;
11
11
  export const CONFIG_FILE_NAME = GLOBAL_CONFIG_FILE_NAME;
@@ -18,7 +18,14 @@ function getLegacyConfigPath() {
18
18
  async function readConfigFile(configPath) {
19
19
  try {
20
20
  const content = await fs.readFile(configPath, 'utf-8');
21
- return { status: 'ok', config: JSON.parse(content) };
21
+ const parsed = JSON.parse(content);
22
+ // Valid JSON that is not an object carries no settings to merge into, and
23
+ // spreading it would replace the file (a string even spreads to numeric
24
+ // character keys). Same predicate the rest of the CLI refuses to save over.
25
+ if (!isConfigRootObject(parsed)) {
26
+ return { status: 'invalid', config: {} };
27
+ }
28
+ return { status: 'ok', config: parsed };
22
29
  }
23
30
  catch (error) {
24
31
  if (error.code === 'ENOENT') {
@@ -108,6 +115,11 @@ export async function readConfig() {
108
115
  */
109
116
  export async function writeConfig(updates) {
110
117
  const configPath = getConfigPath();
118
+ // Never write over a file that did not parse: the merge below would start
119
+ // from an empty object and replace every setting in it with these updates.
120
+ if ((await readConfigFile(configPath)).status === 'invalid') {
121
+ throw new Error(unreadableGlobalConfigMessage(configPath));
122
+ }
111
123
  // Read existing config and merge
112
124
  const existing = await readConfig();
113
125
  const merged = { ...existing, ...updates };
@@ -2,11 +2,12 @@
2
2
  * Check if telemetry is enabled.
3
3
  *
4
4
  * Precedence (first match wins):
5
- * 1. OPENSPEC_TELEMETRY=0 → disabled
6
- * 2. DO_NOT_TRACK=1 → disabled
5
+ * 1. OPENSPEC_TELEMETRY set to anything but an on-value (1/true/yes/on) → disabled
6
+ * 2. DO_NOT_TRACK set to anything but an off-value (0/false/no/off) → disabled
7
7
  * 3. CI set to a truthy/on value → disabled (same rule as version-check)
8
8
  * 4. global config telemetry.enabled === false → disabled
9
- * 5. otherwise enabled (unset config means on; opt-out model)
9
+ * 5. global config file exists but cannot be parsed → disabled
10
+ * 6. otherwise enabled (unset config means on; opt-out model)
10
11
  *
11
12
  * Kept synchronous so call sites need not become async. Reads config via
12
13
  * sync getGlobalConfig() rather than async getTelemetryConfig().
@@ -20,9 +20,10 @@
20
20
  * versions and broke installs (#1390).
21
21
  */
22
22
  import { randomUUID } from 'crypto';
23
- import { getGlobalConfig } from '../core/global-config.js';
23
+ import { getGlobalConfig, isGlobalConfigUnreadable } from '../core/global-config.js';
24
24
  import { isCiEnvironment } from '../utils/ci.js';
25
25
  import { getTelemetryConfig, updateTelemetryConfig } from './config.js';
26
+ import { isTelemetryOptedOutByEnv } from './opt-out.js';
26
27
  // PostHog API key - public key for client-side analytics
27
28
  // This is safe to embed as it only allows sending events, not reading data
28
29
  const POSTHOG_API_KEY = 'phc_Hthu8YvaIJ9QaFKyTG4TbVwkbd5ktcAFzVTKeMmoW2g';
@@ -59,22 +60,21 @@ async function safeTelemetryFetch(url, options) {
59
60
  * Check if telemetry is enabled.
60
61
  *
61
62
  * Precedence (first match wins):
62
- * 1. OPENSPEC_TELEMETRY=0 → disabled
63
- * 2. DO_NOT_TRACK=1 → disabled
63
+ * 1. OPENSPEC_TELEMETRY set to anything but an on-value (1/true/yes/on) → disabled
64
+ * 2. DO_NOT_TRACK set to anything but an off-value (0/false/no/off) → disabled
64
65
  * 3. CI set to a truthy/on value → disabled (same rule as version-check)
65
66
  * 4. global config telemetry.enabled === false → disabled
66
- * 5. otherwise enabled (unset config means on; opt-out model)
67
+ * 5. global config file exists but cannot be parsed → disabled
68
+ * 6. otherwise enabled (unset config means on; opt-out model)
67
69
  *
68
70
  * Kept synchronous so call sites need not become async. Reads config via
69
71
  * sync getGlobalConfig() rather than async getTelemetryConfig().
70
72
  */
71
73
  export function isTelemetryEnabled() {
72
- // Check explicit opt-out
73
- if (process.env.OPENSPEC_TELEMETRY === '0') {
74
- return false;
75
- }
76
- // Respect DO_NOT_TRACK standard
77
- if (process.env.DO_NOT_TRACK === '1') {
74
+ // Explicit opt-out, and the DO_NOT_TRACK standard. Both are read
75
+ // tolerantly (see opt-out.ts): an opt-out that only worked for one exact
76
+ // spelling would leave users tracked who believe they are not.
77
+ if (isTelemetryOptedOutByEnv()) {
78
78
  return false;
79
79
  }
80
80
  // Auto-disable in CI environments (providers use true/1/yes/…)
@@ -85,6 +85,11 @@ export function isTelemetryEnabled() {
85
85
  if (getGlobalConfig().telemetry?.enabled === false) {
86
86
  return false;
87
87
  }
88
+ // A config file that cannot be parsed reads as defaults, which carry no
89
+ // opt-out, but the file itself may hold one. Unknown is not consent.
90
+ if (isGlobalConfigUnreadable()) {
91
+ return false;
92
+ }
88
93
  return true;
89
94
  }
90
95
  /**
@@ -144,6 +149,13 @@ export async function trackCommand(commandName, version) {
144
149
  return;
145
150
  }
146
151
  try {
152
+ // Disclosure before collection. A --json run defers the notice (printing
153
+ // it would corrupt machine-readable output), and for a user whose runs
154
+ // are all --json it may never have appeared — so nothing is sent, and no
155
+ // anonymous id is created, until the notice has actually been shown.
156
+ if (!(await getTelemetryConfig()).noticeSeen) {
157
+ return;
158
+ }
147
159
  const userId = await getOrCreateAnonymousId();
148
160
  sendEvent(userId, 'command_executed', {
149
161
  command: commandName,
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Telemetry opt-out signals, shared by telemetry and the version check so both
3
+ * outbound surfaces honor exactly the same values.
4
+ *
5
+ * Parsing is tolerant in the style of isCiEnvironment(): a user who wrote
6
+ * DO_NOT_TRACK=true meant it, and an exact-match test that silently kept
7
+ * sending would be a privacy control that does not work. Ambiguity fails
8
+ * safe — a value we cannot read as "on" suppresses the request rather than
9
+ * enabling it.
10
+ */
11
+ /** True when `DO_NOT_TRACK` is set to anything but an explicit off-value. */
12
+ export declare function isDoNotTrackSet(env?: NodeJS.ProcessEnv): boolean;
13
+ /** True when `OPENSPEC_TELEMETRY` is set to anything but an explicit on-value. */
14
+ export declare function isTelemetryDisabledByEnv(env?: NodeJS.ProcessEnv): boolean;
15
+ /** True when either opt-out signal asks us to stay off the network. */
16
+ export declare function isTelemetryOptedOutByEnv(env?: NodeJS.ProcessEnv): boolean;
17
+ //# sourceMappingURL=opt-out.d.ts.map
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Telemetry opt-out signals, shared by telemetry and the version check so both
3
+ * outbound surfaces honor exactly the same values.
4
+ *
5
+ * Parsing is tolerant in the style of isCiEnvironment(): a user who wrote
6
+ * DO_NOT_TRACK=true meant it, and an exact-match test that silently kept
7
+ * sending would be a privacy control that does not work. Ambiguity fails
8
+ * safe — a value we cannot read as "on" suppresses the request rather than
9
+ * enabling it.
10
+ */
11
+ const ON_VALUES = new Set(['1', 'true', 'yes', 'on']);
12
+ const OFF_VALUES = new Set(['', '0', 'false', 'no', 'off']);
13
+ /** True when `DO_NOT_TRACK` is set to anything but an explicit off-value. */
14
+ export function isDoNotTrackSet(env = process.env) {
15
+ const value = env.DO_NOT_TRACK;
16
+ return value !== undefined && !OFF_VALUES.has(value.trim().toLowerCase());
17
+ }
18
+ /** True when `OPENSPEC_TELEMETRY` is set to anything but an explicit on-value. */
19
+ export function isTelemetryDisabledByEnv(env = process.env) {
20
+ const value = env.OPENSPEC_TELEMETRY;
21
+ return value !== undefined && !ON_VALUES.has(value.trim().toLowerCase());
22
+ }
23
+ /** True when either opt-out signal asks us to stay off the network. */
24
+ export function isTelemetryOptedOutByEnv(env = process.env) {
25
+ return isTelemetryDisabledByEnv(env) || isDoNotTrackSet(env);
26
+ }
27
+ //# sourceMappingURL=opt-out.js.map
@@ -1,5 +1,6 @@
1
1
  import * as nodeFs from 'fs';
2
2
  import path from 'path';
3
+ import { detectLineEnding, matchLineEnding } from './line-endings.js';
3
4
  const fs = nodeFs.promises;
4
5
  const { constants: fsConstants } = nodeFs;
5
6
  function hasOwnerGroupOrOtherWriteBit(stats) {
@@ -277,8 +278,14 @@ export class FileSystemUtils {
277
278
  }
278
279
  static async updateFileWithMarkers(filePath, content, startMarker, endMarker) {
279
280
  let existingContent = '';
281
+ // The managed block is composed with '\n', so splicing it into a CRLF file
282
+ // would leave mixed endings behind. bash reports a stray '\r' in .bashrc as
283
+ // "$'\r': command not found", so settle the whole file on the convention it
284
+ // already used. A file that does not exist yet stays LF.
285
+ let originalContent;
280
286
  if (await this.fileExists(filePath)) {
281
287
  existingContent = await this.readFile(filePath);
288
+ originalContent = existingContent;
282
289
  const startIndex = findMarkerIndex(existingContent, startMarker);
283
290
  const endIndex = startIndex !== -1
284
291
  ? findMarkerIndex(existingContent, endMarker, startIndex + startMarker.length)
@@ -301,7 +308,9 @@ export class FileSystemUtils {
301
308
  else {
302
309
  existingContent = startMarker + '\n' + content + '\n' + endMarker;
303
310
  }
304
- await this.writeFile(filePath, existingContent);
311
+ await this.writeFile(filePath, originalContent === undefined
312
+ ? existingContent
313
+ : matchLineEnding(existingContent, originalContent));
305
314
  }
306
315
  static async ensureWritePermissions(dirPath) {
307
316
  try {
@@ -377,14 +386,22 @@ export function removeMarkerBlock(content, startMarker, endMarker) {
377
386
  }
378
387
  const before = content.substring(0, lineStart);
379
388
  const after = content.substring(lineEnd);
389
+ // The file's own newline, used for every ending this function writes. The
390
+ // blank-line collapse below rebuilds the separator it matched, so spelling it
391
+ // '\n' would leave a CRLF file with a mixed pair wherever a run was collapsed
392
+ // - the stray '\r' that bash reports as "$'\r': command not found".
393
+ //
394
+ // Dominant rather than "contains a CRLF anywhere", so that one stray CRLF in
395
+ // an otherwise-LF file does not pull the whole rewrite to CRLF. This is the
396
+ // same reading matchLineEnding uses, so both write paths agree.
397
+ const newline = detectLineEnding(content) ?? '\n';
380
398
  // Clean up double blank lines (handle both Unix \n and Windows \r\n)
381
399
  let result = before + after;
382
- result = result.replace(/(\r?\n){3,}/g, '\n\n');
400
+ result = result.replace(/(\r?\n){3,}/g, newline + newline);
383
401
  // Trim trailing whitespace but preserve leading whitespace and original newline style
384
402
  if (result.trimEnd() === '') {
385
403
  return '';
386
404
  }
387
- const newline = content.includes('\r\n') ? '\r\n' : '\n';
388
405
  return result.trimEnd() + newline;
389
406
  }
390
407
  //# sourceMappingURL=file-system.js.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Line-ending helpers.
3
+ *
4
+ * Every parser in this codebase normalizes CRLF to LF on the way in, so all
5
+ * serialization logic can assume '\n'. That leaves the write side responsible
6
+ * for restoring whatever convention the file already used: without it, editing
7
+ * one requirement in a CRLF spec rewrites every line of the file and buries the
8
+ * real change in the diff.
9
+ */
10
+ export type LineEnding = '\n' | '\r\n';
11
+ /**
12
+ * The dominant line ending in `content`, or undefined when it holds no line
13
+ * break to judge from.
14
+ *
15
+ * Mixed files resolve to whichever ending is more common, with CRLF winning a
16
+ * tie: a file that is mostly CRLF is a CRLF file that picked up a stray LF, and
17
+ * settling the whole file on one ending is what keeps later diffs small.
18
+ */
19
+ export declare function detectLineEnding(content: string): LineEnding | undefined;
20
+ /**
21
+ * Re-apply `ending` to LF-normalized `content`.
22
+ *
23
+ * Normalizes to LF first, so the result is uniform even if the caller passed
24
+ * content that already contained CRLF.
25
+ */
26
+ export declare function applyLineEnding(content: string, ending: LineEnding): string;
27
+ /**
28
+ * Rewrite `content` to match the convention of `original`.
29
+ *
30
+ * When `original` has no line break to learn from, `content` is left as LF —
31
+ * the portable default this project writes new files with.
32
+ */
33
+ export declare function matchLineEnding(content: string, original: string): string;
34
+ //# sourceMappingURL=line-endings.d.ts.map
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Line-ending helpers.
3
+ *
4
+ * Every parser in this codebase normalizes CRLF to LF on the way in, so all
5
+ * serialization logic can assume '\n'. That leaves the write side responsible
6
+ * for restoring whatever convention the file already used: without it, editing
7
+ * one requirement in a CRLF spec rewrites every line of the file and buries the
8
+ * real change in the diff.
9
+ */
10
+ /**
11
+ * The dominant line ending in `content`, or undefined when it holds no line
12
+ * break to judge from.
13
+ *
14
+ * Mixed files resolve to whichever ending is more common, with CRLF winning a
15
+ * tie: a file that is mostly CRLF is a CRLF file that picked up a stray LF, and
16
+ * settling the whole file on one ending is what keeps later diffs small.
17
+ */
18
+ export function detectLineEnding(content) {
19
+ const crlf = content.match(/\r\n/g)?.length ?? 0;
20
+ // Count LFs not preceded by CR, so CRLF is never also counted as LF.
21
+ const lf = content.match(/(?<!\r)\n/g)?.length ?? 0;
22
+ if (crlf === 0 && lf === 0)
23
+ return undefined;
24
+ return crlf >= lf ? '\r\n' : '\n';
25
+ }
26
+ /**
27
+ * Re-apply `ending` to LF-normalized `content`.
28
+ *
29
+ * Normalizes to LF first, so the result is uniform even if the caller passed
30
+ * content that already contained CRLF.
31
+ */
32
+ export function applyLineEnding(content, ending) {
33
+ const normalized = content.replace(/\r\n/g, '\n');
34
+ return ending === '\n' ? normalized : normalized.replace(/\n/g, '\r\n');
35
+ }
36
+ /**
37
+ * Rewrite `content` to match the convention of `original`.
38
+ *
39
+ * When `original` has no line break to learn from, `content` is left as LF —
40
+ * the portable default this project writes new files with.
41
+ */
42
+ export function matchLineEnding(content, original) {
43
+ const ending = detectLineEnding(original);
44
+ return ending === undefined ? content : applyLineEnding(content, ending);
45
+ }
46
+ //# sourceMappingURL=line-endings.js.map
@@ -0,0 +1,35 @@
1
+ /** A namespace folder under `changes/` and the change directories buried in it. */
2
+ export interface NestedChangeFinding {
3
+ /** The directory name directly under `changes/` that is not a change. */
4
+ name: string;
5
+ /**
6
+ * Slash-separated paths of the nested change directories, relative to
7
+ * `changes/`, sorted. Always at least one entry.
8
+ */
9
+ nested: string[];
10
+ }
11
+ /**
12
+ * Reports whether the directory `changes/<name>/` is a namespace folder holding
13
+ * one or more nested change directories rather than a change of its own.
14
+ *
15
+ * Returns undefined for every ordinary change, including a scaffolded one with
16
+ * no artifacts yet: only a *nested* change directory distinguishes the two.
17
+ */
18
+ export declare function findNestedChangesIn(changesDir: string, name: string): Promise<NestedChangeFinding | undefined>;
19
+ /**
20
+ * The same check across every candidate directory under `changes/`, for the
21
+ * commands that enumerate rather than resolve a single name.
22
+ */
23
+ export declare function findNestedChanges(changesDir: string, names: string[]): Promise<NestedChangeFinding[]>;
24
+ /**
25
+ * The stable phrase every nested-change diagnostic contains. Callers that have
26
+ * only the rendered message - `validate`'s next-steps, which branches on the
27
+ * exact issue it is advising about - recognise the case by this.
28
+ */
29
+ export declare const NESTED_CHANGE_ISSUE_MARKER = "is not a change: it is a folder wrapping";
30
+ /**
31
+ * The one explanation every surface prints, so the constraint and the way out
32
+ * read identically wherever a user meets them.
33
+ */
34
+ export declare function describeNestedChange(finding: NestedChangeFinding): string;
35
+ //# sourceMappingURL=nested-change.d.ts.map