@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.
- package/README.md +16 -4
- package/dist/commands/change.js +12 -1
- package/dist/commands/config.d.ts +9 -0
- package/dist/commands/config.js +161 -22
- package/dist/commands/feedback.js +6 -4
- package/dist/commands/schema.js +30 -9
- package/dist/commands/store.js +5 -2
- package/dist/commands/validate.d.ts +7 -0
- package/dist/commands/validate.js +63 -1
- package/dist/commands/workflow/instructions.js +69 -22
- package/dist/commands/workflow/new-change.js +20 -0
- package/dist/commands/workflow/shared.d.ts +5 -0
- package/dist/commands/workflow/shared.js +8 -0
- package/dist/commands/workflow/status.d.ts +5 -1
- package/dist/commands/workflow/status.js +42 -5
- package/dist/core/archive.js +260 -77
- package/dist/core/artifact-graph/outputs.d.ts +1 -1
- package/dist/core/artifact-graph/outputs.js +38 -8
- package/dist/core/artifact-graph/schema.d.ts +14 -0
- package/dist/core/artifact-graph/schema.js +52 -0
- package/dist/core/artifact-graph/types.js +12 -1
- package/dist/core/change-status-policy.d.ts +14 -0
- package/dist/core/change-status-policy.js +14 -6
- package/dist/core/command-generation/adapters/continue.js +4 -0
- package/dist/core/command-generation/adapters/kilocode.d.ts +1 -1
- package/dist/core/command-generation/adapters/kilocode.js +2 -2
- package/dist/core/command-generation/generator.js +2 -0
- package/dist/core/completion-tip.js +7 -7
- package/dist/core/completions/installers/bash-installer.js +15 -7
- package/dist/core/completions/installers/shell-quote.d.ts +11 -0
- package/dist/core/completions/installers/shell-quote.js +13 -0
- package/dist/core/completions/installers/zsh-installer.js +3 -2
- package/dist/core/config-schema.d.ts +1 -1
- package/dist/core/config.d.ts +16 -0
- package/dist/core/config.js +27 -1
- package/dist/core/global-config.d.ts +30 -2
- package/dist/core/global-config.js +57 -3
- package/dist/core/init.js +22 -6
- package/dist/core/legacy-cleanup.d.ts +5 -1
- package/dist/core/legacy-cleanup.js +207 -14
- package/dist/core/list.js +55 -11
- package/dist/core/migration.js +6 -1
- package/dist/core/parsers/change-parser.d.ts +12 -1
- package/dist/core/parsers/change-parser.js +85 -87
- package/dist/core/parsers/markdown-parser.js +4 -3
- package/dist/core/parsers/requirement-blocks.d.ts +78 -6
- package/dist/core/parsers/requirement-blocks.js +155 -38
- package/dist/core/parsers/requirement-text.d.ts +13 -2
- package/dist/core/parsers/requirement-text.js +34 -7
- package/dist/core/parsers/spec-structure.js +4 -1
- package/dist/core/project-config.d.ts +9 -1
- package/dist/core/project-config.js +11 -2
- package/dist/core/references.d.ts +20 -0
- package/dist/core/references.js +56 -0
- package/dist/core/shared/skill-content-equivalence.js +5 -1
- package/dist/core/shared/skill-generation.js +36 -8
- package/dist/core/shared/tool-detection.js +9 -3
- package/dist/core/specs-apply.d.ts +20 -0
- package/dist/core/specs-apply.js +101 -17
- package/dist/core/store/git.d.ts +33 -0
- package/dist/core/store/git.js +60 -6
- package/dist/core/store/operations.d.ts +1 -1
- package/dist/core/store/operations.js +32 -10
- package/dist/core/store/registry.d.ts +2 -1
- package/dist/core/store/registry.js +5 -1
- package/dist/core/templates/optional-workflow.d.ts +69 -0
- package/dist/core/templates/optional-workflow.js +140 -0
- package/dist/core/templates/workflows/apply-change.js +18 -3
- package/dist/core/templates/workflows/archive-change.js +70 -21
- package/dist/core/templates/workflows/bulk-archive-change.js +85 -21
- package/dist/core/templates/workflows/continue-change.js +14 -4
- package/dist/core/templates/workflows/explore.js +34 -16
- package/dist/core/templates/workflows/ff-change.js +21 -5
- package/dist/core/templates/workflows/new-change.js +15 -3
- package/dist/core/templates/workflows/onboard.js +76 -41
- package/dist/core/templates/workflows/project-root.d.ts +29 -0
- package/dist/core/templates/workflows/project-root.js +38 -0
- package/dist/core/templates/workflows/propose.js +22 -5
- package/dist/core/templates/workflows/sync-specs.js +40 -1
- package/dist/core/templates/workflows/update-change.js +66 -27
- package/dist/core/templates/workflows/verify-change.js +121 -49
- package/dist/core/update.d.ts +12 -0
- package/dist/core/update.js +78 -10
- package/dist/core/validation/purpose-placeholder.js +24 -9
- package/dist/core/validation/task-checkboxes.d.ts +34 -0
- package/dist/core/validation/task-checkboxes.js +181 -0
- package/dist/core/validation/validator.d.ts +53 -1
- package/dist/core/validation/validator.js +160 -45
- package/dist/core/version-check.d.ts +14 -7
- package/dist/core/version-check.js +63 -17
- package/dist/core/worksets.js +6 -4
- package/dist/prompts/searchable-multi-select.d.ts +7 -0
- package/dist/prompts/searchable-multi-select.js +35 -6
- package/dist/telemetry/config.js +14 -2
- package/dist/telemetry/index.d.ts +4 -3
- package/dist/telemetry/index.js +22 -10
- package/dist/telemetry/opt-out.d.ts +17 -0
- package/dist/telemetry/opt-out.js +27 -0
- package/dist/utils/file-system.js +20 -3
- package/dist/utils/line-endings.d.ts +34 -0
- package/dist/utils/line-endings.js +46 -0
- package/dist/utils/nested-change.d.ts +35 -0
- package/dist/utils/nested-change.js +186 -0
- package/dist/utils/spec-discovery.d.ts +20 -0
- package/dist/utils/spec-discovery.js +59 -0
- package/dist/utils/task-progress.d.ts +1 -1
- package/dist/utils/task-progress.js +38 -6
- package/package.json +5 -5
- package/schemas/spec-driven/schema.yaml +18 -3
- package/schemas/spec-driven/templates/design.md +2 -0
- package/schemas/spec-driven/templates/proposal.md +2 -0
- package/schemas/spec-driven/templates/spec.md +2 -0
- 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 {
|
|
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 (
|
|
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
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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
|
-
|
|
52
|
+
function configuredRegistry() {
|
|
54
53
|
const configured = process.env.npm_config_registry?.trim();
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
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
|
package/dist/core/worksets.js
CHANGED
|
@@ -175,7 +175,7 @@ export function worksetNotFoundError(name, state) {
|
|
|
175
175
|
});
|
|
176
176
|
}
|
|
177
177
|
export function withWorkset(state, workset) {
|
|
178
|
-
if (state.worksets
|
|
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
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
package/dist/telemetry/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
6
|
-
* 2. DO_NOT_TRACK
|
|
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.
|
|
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().
|
package/dist/telemetry/index.js
CHANGED
|
@@ -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
|
|
63
|
-
* 2. DO_NOT_TRACK
|
|
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.
|
|
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
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
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,
|
|
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,
|
|
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
|