@fission-ai/openspec 1.14.3 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -10
- package/dist/cli/index.js +47 -0
- package/dist/commands/completion.js +4 -2
- package/dist/commands/feedback.js +33 -20
- package/dist/commands/install-standalone.d.ts +27 -0
- package/dist/commands/install-standalone.js +150 -0
- package/dist/commands/schema.js +10 -3
- package/dist/commands/validate.js +33 -3
- package/dist/commands/workflow/instructions.js +15 -4
- package/dist/commands/workflow/new-change.js +12 -4
- package/dist/commands/workflow/status.js +5 -2
- package/dist/commands/workflow/templates.js +5 -2
- package/dist/core/artifact-graph/resolver.d.ts +6 -0
- package/dist/core/artifact-graph/resolver.js +11 -0
- package/dist/core/available-tools.d.ts +2 -1
- package/dist/core/available-tools.js +8 -28
- package/dist/core/completion-tip.d.ts +23 -0
- package/dist/core/completion-tip.js +4 -4
- package/dist/core/completions/command-registry.js +15 -0
- package/dist/core/completions/installers/zsh-installer.d.ts +9 -1
- package/dist/core/completions/installers/zsh-installer.js +64 -19
- package/dist/core/config-schema.d.ts +1 -0
- package/dist/core/config-schema.js +1 -0
- package/dist/core/file-view.d.ts +13 -0
- package/dist/core/file-view.js +14 -0
- package/dist/core/github-copilot/cloud-agent.d.ts +10 -2
- package/dist/core/github-copilot/cloud-agent.js +13 -9
- package/dist/core/global-config.d.ts +4 -0
- package/dist/core/global-config.js +5 -1
- package/dist/core/id.d.ts +8 -0
- package/dist/core/id.js +15 -0
- package/dist/core/legacy-cleanup.d.ts +11 -1
- package/dist/core/legacy-cleanup.js +15 -18
- package/dist/core/migration.d.ts +27 -1
- package/dist/core/migration.js +77 -29
- package/dist/core/parsers/code-fence.js +9 -1
- package/dist/core/parsers/requirement-blocks.d.ts +30 -0
- package/dist/core/parsers/requirement-blocks.js +22 -9
- package/dist/core/parsers/requirement-text.d.ts +14 -0
- package/dist/core/parsers/requirement-text.js +39 -0
- package/dist/core/parsers/spec-structure.js +1 -1
- package/dist/core/profile-sync-drift.d.ts +4 -3
- package/dist/core/profile-sync-drift.js +19 -16
- package/dist/core/project-config.d.ts +10 -0
- package/dist/core/project-config.js +26 -0
- package/dist/core/root-selection.d.ts +6 -0
- package/dist/core/root-selection.js +37 -0
- package/dist/core/shared/tool-detection.d.ts +12 -4
- package/dist/core/shared/tool-detection.js +29 -27
- package/dist/core/shared-skill-target.d.ts +12 -5
- package/dist/core/shared-skill-target.js +34 -23
- package/dist/core/standalone-tip.d.ts +6 -0
- package/dist/core/standalone-tip.js +38 -0
- package/dist/core/store/git.d.ts +22 -1
- package/dist/core/store/git.js +59 -4
- package/dist/core/templates/workflows/verify-change.js +4 -4
- package/dist/core/update-plan.d.ts +127 -0
- package/dist/core/update-plan.js +390 -0
- package/dist/core/update.d.ts +45 -1
- package/dist/core/update.js +279 -137
- package/dist/core/validation/validator.d.ts +27 -2
- package/dist/core/validation/validator.js +58 -4
- package/dist/core/version-check.d.ts +8 -1
- package/dist/core/version-check.js +48 -5
- package/dist/utils/executable-path.d.ts +5 -0
- package/dist/utils/executable-path.js +44 -0
- package/package.json +5 -4
- package/schemas/spec-driven/schema.yaml +1 -0
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Displays artifact completion status for a change.
|
|
5
5
|
*/
|
|
6
|
-
import ora from 'ora';
|
|
7
6
|
import chalk from 'chalk';
|
|
8
7
|
import { getChangeDir } from '../../core/planning-home.js';
|
|
9
8
|
import { resolveRootForCommand, toPlanningHome, toRootOutput, withStoreFlag, isStoreSelectedRoot, findDeclaringProjectRoot, } from '../../core/root-selection.js';
|
|
@@ -33,7 +32,11 @@ export async function statusCommand(options) {
|
|
|
33
32
|
if (!root) {
|
|
34
33
|
return;
|
|
35
34
|
}
|
|
36
|
-
|
|
35
|
+
let spinner;
|
|
36
|
+
if (!options.json) {
|
|
37
|
+
const { default: ora } = await import('ora');
|
|
38
|
+
spinner = ora('Loading change status...').start();
|
|
39
|
+
}
|
|
37
40
|
try {
|
|
38
41
|
const planningHome = toPlanningHome(root);
|
|
39
42
|
const projectRoot = root.path;
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Shows resolved template paths for all artifacts in a schema.
|
|
5
5
|
*/
|
|
6
|
-
import ora from 'ora';
|
|
7
6
|
import path from 'path';
|
|
8
7
|
import { resolveSchema, getSchemaDir, ArtifactGraph, } from '../../core/artifact-graph/index.js';
|
|
9
8
|
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
@@ -12,7 +11,11 @@ import { validateSchemaExists, DEFAULT_SCHEMA } from './shared.js';
|
|
|
12
11
|
// Command Implementation
|
|
13
12
|
// -----------------------------------------------------------------------------
|
|
14
13
|
export async function templatesCommand(options) {
|
|
15
|
-
|
|
14
|
+
let spinner;
|
|
15
|
+
if (!options.json) {
|
|
16
|
+
const { default: ora } = await import('ora');
|
|
17
|
+
spinner = ora('Loading templates...').start();
|
|
18
|
+
}
|
|
16
19
|
try {
|
|
17
20
|
const projectRoot = process.cwd();
|
|
18
21
|
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA, projectRoot);
|
|
@@ -8,6 +8,12 @@ export declare class SchemaLoadError extends Error {
|
|
|
8
8
|
readonly cause?: Error | undefined;
|
|
9
9
|
constructor(message: string, schemaPath: string, cause?: Error | undefined);
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Points the built-in schemas at another directory. The standalone binary has
|
|
13
|
+
* no package directory on disk, so it passes a function that extracts its
|
|
14
|
+
* embedded schemas; it runs only when a command first needs them.
|
|
15
|
+
*/
|
|
16
|
+
export declare function setPackageSchemasDir(resolveDir: () => string): void;
|
|
11
17
|
/**
|
|
12
18
|
* Gets the package's built-in schemas directory path.
|
|
13
19
|
* Uses import.meta.url to resolve relative to the current module.
|
|
@@ -17,11 +17,22 @@ export class SchemaLoadError extends Error {
|
|
|
17
17
|
this.name = 'SchemaLoadError';
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
+
let packageSchemasDirOverride;
|
|
21
|
+
/**
|
|
22
|
+
* Points the built-in schemas at another directory. The standalone binary has
|
|
23
|
+
* no package directory on disk, so it passes a function that extracts its
|
|
24
|
+
* embedded schemas; it runs only when a command first needs them.
|
|
25
|
+
*/
|
|
26
|
+
export function setPackageSchemasDir(resolveDir) {
|
|
27
|
+
packageSchemasDirOverride = resolveDir;
|
|
28
|
+
}
|
|
20
29
|
/**
|
|
21
30
|
* Gets the package's built-in schemas directory path.
|
|
22
31
|
* Uses import.meta.url to resolve relative to the current module.
|
|
23
32
|
*/
|
|
24
33
|
export function getPackageSchemasDir() {
|
|
34
|
+
if (packageSchemasDirOverride)
|
|
35
|
+
return packageSchemasDirOverride();
|
|
25
36
|
const currentFile = fileURLToPath(import.meta.url);
|
|
26
37
|
// Navigate from dist/core/artifact-graph/ to package root's schemas/
|
|
27
38
|
return path.join(path.dirname(currentFile), '..', '..', '..', 'schemas');
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* for their configuration directories.
|
|
6
6
|
*/
|
|
7
7
|
import { type AIToolOption } from './config.js';
|
|
8
|
+
import { type FileView } from './file-view.js';
|
|
8
9
|
/**
|
|
9
10
|
* Scans the project path for AI tool configuration directories and returns
|
|
10
11
|
* the tools that are present.
|
|
@@ -13,5 +14,5 @@ import { type AIToolOption } from './config.js';
|
|
|
13
14
|
* directories). Otherwise checks the project's `skillsDir`, or managed skill
|
|
14
15
|
* files in the user's home directory for a global skill target.
|
|
15
16
|
*/
|
|
16
|
-
export declare function getAvailableTools(projectPath: string): AIToolOption[];
|
|
17
|
+
export declare function getAvailableTools(projectPath: string, view?: FileView): AIToolOption[];
|
|
17
18
|
//# sourceMappingURL=available-tools.d.ts.map
|
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
* for their configuration directories.
|
|
6
6
|
*/
|
|
7
7
|
import path from 'path';
|
|
8
|
-
import * as fs from 'fs';
|
|
9
8
|
import { AI_TOOLS } from './config.js';
|
|
10
9
|
import { reconcileSharedSkillTargets } from './shared-skill-target.js';
|
|
11
10
|
import { SKILL_NAMES } from './shared/tool-detection.js';
|
|
12
11
|
import { resolveToolSkillsDir, toolSupportsSkills } from './shared/skill-paths.js';
|
|
12
|
+
import { DISK_VIEW } from './file-view.js';
|
|
13
13
|
/**
|
|
14
14
|
* Scans the project path for AI tool configuration directories and returns
|
|
15
15
|
* the tools that are present.
|
|
@@ -18,49 +18,29 @@ import { resolveToolSkillsDir, toolSupportsSkills } from './shared/skill-paths.j
|
|
|
18
18
|
* directories). Otherwise checks the project's `skillsDir`, or managed skill
|
|
19
19
|
* files in the user's home directory for a global skill target.
|
|
20
20
|
*/
|
|
21
|
-
export function getAvailableTools(projectPath) {
|
|
21
|
+
export function getAvailableTools(projectPath, view = DISK_VIEW) {
|
|
22
22
|
const available = AI_TOOLS.filter((tool) => {
|
|
23
23
|
if (!toolSupportsSkills(tool))
|
|
24
24
|
return false;
|
|
25
25
|
if (tool.globalSkillsDir) {
|
|
26
26
|
const skillsDir = resolveToolSkillsDir(projectPath, tool);
|
|
27
|
-
return SKILL_NAMES.some((skillName) =>
|
|
27
|
+
return SKILL_NAMES.some((skillName) => view.exists(path.join(skillsDir, skillName, 'SKILL.md')));
|
|
28
28
|
}
|
|
29
29
|
if (!tool.skillsDir)
|
|
30
30
|
return false;
|
|
31
31
|
if (tool.detectionPaths && tool.detectionPaths.length > 0) {
|
|
32
|
-
//
|
|
33
|
-
return tool.detectionPaths.some((p) =>
|
|
34
|
-
try {
|
|
35
|
-
fs.statSync(path.join(projectPath, p));
|
|
36
|
-
return true;
|
|
37
|
-
}
|
|
38
|
-
catch {
|
|
39
|
-
return false;
|
|
40
|
-
}
|
|
41
|
-
});
|
|
42
|
-
}
|
|
43
|
-
const dirPath = path.join(projectPath, tool.skillsDir);
|
|
44
|
-
try {
|
|
45
|
-
return fs.statSync(dirPath).isDirectory();
|
|
46
|
-
}
|
|
47
|
-
catch {
|
|
48
|
-
return false;
|
|
32
|
+
// Presence, not type — detection paths can be files or directories
|
|
33
|
+
return tool.detectionPaths.some((p) => view.exists(path.join(projectPath, p)));
|
|
49
34
|
}
|
|
35
|
+
return view.isDirectory(path.join(projectPath, tool.skillsDir));
|
|
50
36
|
});
|
|
51
|
-
const activeProjectTools = new Set(reconcileSharedSkillTargets(projectPath, available.filter((tool) => tool.skillsDir)).map((tool) => tool.value));
|
|
37
|
+
const activeProjectTools = new Set(reconcileSharedSkillTargets(projectPath, available.filter((tool) => tool.skillsDir), view).map((tool) => tool.value));
|
|
52
38
|
const hasIndependentDetectionPath = (tool) => (tool.detectionPaths ?? []).some((detectionPath) => {
|
|
53
39
|
// Skill roots still go through managed-content reconciliation below;
|
|
54
40
|
// their mere existence is not an independent tool signal.
|
|
55
41
|
if (detectionPath.endsWith('/skills'))
|
|
56
42
|
return false;
|
|
57
|
-
|
|
58
|
-
fs.statSync(path.join(projectPath, detectionPath));
|
|
59
|
-
return true;
|
|
60
|
-
}
|
|
61
|
-
catch {
|
|
62
|
-
return false;
|
|
63
|
-
}
|
|
43
|
+
return view.exists(path.join(projectPath, detectionPath));
|
|
64
44
|
});
|
|
65
45
|
return available.filter((tool) => tool.globalSkillsDir ||
|
|
66
46
|
hasIndependentDetectionPath(tool) ||
|
|
@@ -7,6 +7,29 @@ export interface CompletionTipOptions {
|
|
|
7
7
|
*/
|
|
8
8
|
silent?: boolean;
|
|
9
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* Read the global config exactly as it sits on disk.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately NOT `getGlobalConfig()`: that merges in defaults, and writing the
|
|
14
|
+
* merged result back would stamp `profile`/`delivery` into a file the user never
|
|
15
|
+
* set them in. `migrateIfNeeded` treats a raw `profile` as "already migrated",
|
|
16
|
+
* so that stamp would permanently suppress the one-time profile migration and
|
|
17
|
+
* cost users their installed workflow skills.
|
|
18
|
+
*
|
|
19
|
+
* Returns null when the file exists but cannot be read or parsed — a config we
|
|
20
|
+
* cannot understand is left strictly alone rather than overwritten.
|
|
21
|
+
*/
|
|
22
|
+
export declare function readRawConfig(): Record<string, unknown> | null;
|
|
23
|
+
/**
|
|
24
|
+
* Record the flag, re-reading the config first and replacing the file by rename.
|
|
25
|
+
*
|
|
26
|
+
* Deciding whether to show the tip costs a `ps` spawn and a stat, and a sibling
|
|
27
|
+
* `openspec` process can write the same file in that window — on a first run
|
|
28
|
+
* that is exactly when telemetry mints `anonymousId`. Re-reading here keeps the
|
|
29
|
+
* write down to this one key, and the rename keeps a reader from ever seeing a
|
|
30
|
+
* half-written config.
|
|
31
|
+
*/
|
|
32
|
+
export declare function markConfigFlag(key: string): Promise<void>;
|
|
10
33
|
/**
|
|
11
34
|
* Print the completion tip once, the first time the CLI runs.
|
|
12
35
|
* Never throws — a hint must not break a command.
|
|
@@ -76,7 +76,7 @@ async function decideTip() {
|
|
|
76
76
|
* Returns null when the file exists but cannot be read or parsed — a config we
|
|
77
77
|
* cannot understand is left strictly alone rather than overwritten.
|
|
78
78
|
*/
|
|
79
|
-
function readRawConfig() {
|
|
79
|
+
export function readRawConfig() {
|
|
80
80
|
const configPath = getGlobalConfigPath();
|
|
81
81
|
if (!fs.existsSync(configPath)) {
|
|
82
82
|
return {};
|
|
@@ -96,13 +96,13 @@ function readRawConfig() {
|
|
|
96
96
|
* write down to this one key, and the rename keeps a reader from ever seeing a
|
|
97
97
|
* half-written config.
|
|
98
98
|
*/
|
|
99
|
-
async function
|
|
99
|
+
export async function markConfigFlag(key) {
|
|
100
100
|
const configPath = getGlobalConfigPath();
|
|
101
101
|
const current = readRawConfig() ?? {};
|
|
102
102
|
// The shared atomic writer: randomized temp name, owner-only mode, temp file
|
|
103
103
|
// removed on failure. A predictable `<config>.<pid>.tmp` at the default mode
|
|
104
104
|
// is both guessable and world-readable once renamed over the config.
|
|
105
|
-
await writeFileAtomically(configPath, JSON.stringify({ ...current,
|
|
105
|
+
await writeFileAtomically(configPath, JSON.stringify({ ...current, [key]: true }, null, 2) + '\n');
|
|
106
106
|
}
|
|
107
107
|
/**
|
|
108
108
|
* Print the completion tip once, the first time the CLI runs.
|
|
@@ -123,7 +123,7 @@ export async function maybeShowCompletionTip(options = {}) {
|
|
|
123
123
|
const decision = await decideTip();
|
|
124
124
|
// Record before printing: if the flag cannot be persisted, staying quiet
|
|
125
125
|
// beats reprinting the tip on every future run.
|
|
126
|
-
await
|
|
126
|
+
await markConfigFlag('completionTipSeen');
|
|
127
127
|
if (decision === 'show') {
|
|
128
128
|
console.error(`\n${COMPLETION_TIP_MESSAGE}`);
|
|
129
129
|
}
|
|
@@ -52,8 +52,23 @@ export const COMMAND_REGISTRY = [
|
|
|
52
52
|
name: 'force',
|
|
53
53
|
description: 'Force update even when tools are up to date',
|
|
54
54
|
},
|
|
55
|
+
{
|
|
56
|
+
name: 'dry-run',
|
|
57
|
+
description: 'Report what the update would change without writing anything',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: 'json',
|
|
61
|
+
description: 'With --dry-run, print the planned changes as JSON',
|
|
62
|
+
},
|
|
55
63
|
],
|
|
56
64
|
},
|
|
65
|
+
{
|
|
66
|
+
name: 'install',
|
|
67
|
+
description: 'Install the standalone openspec, which runs without Node.js (latest release unless [version] is given)',
|
|
68
|
+
acceptsPositional: true,
|
|
69
|
+
positionals: [{ name: 'version', optional: true }],
|
|
70
|
+
flags: [],
|
|
71
|
+
},
|
|
57
72
|
{
|
|
58
73
|
name: 'version',
|
|
59
74
|
description: 'Report the installed OpenSpec version and update availability',
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { InstallationResult } from '../factory.js';
|
|
2
|
+
/** The .zshrc zsh will read, as shown to the user: `~/.zshrc` unless ZDOTDIR moves it. */
|
|
3
|
+
export declare function zshrcDisplayPath(homeDir?: string): string;
|
|
2
4
|
/**
|
|
3
5
|
* Installer for Zsh completion scripts.
|
|
4
6
|
* Supports both Oh My Zsh and standard Zsh configurations.
|
|
@@ -47,6 +49,11 @@ export declare class ZshInstaller {
|
|
|
47
49
|
* @returns Path to .zshrc
|
|
48
50
|
*/
|
|
49
51
|
private getZshrcPath;
|
|
52
|
+
/**
|
|
53
|
+
* Every .zshrc that may hold an OpenSpec block: the one zsh reads, plus
|
|
54
|
+
* ~/.zshrc, where installs before #2138 wrote it even under ZDOTDIR.
|
|
55
|
+
*/
|
|
56
|
+
private getZshrcCandidates;
|
|
50
57
|
/**
|
|
51
58
|
* Generate .zshrc configuration content
|
|
52
59
|
*
|
|
@@ -72,9 +79,10 @@ export declare class ZshInstaller {
|
|
|
72
79
|
* Remove .zshrc configuration
|
|
73
80
|
* Used during uninstallation
|
|
74
81
|
*
|
|
82
|
+
* @param zshrcPath - The .zshrc to clean; defaults to the one zsh reads
|
|
75
83
|
* @returns true if removed successfully, false otherwise
|
|
76
84
|
*/
|
|
77
|
-
removeZshrcConfig(): Promise<boolean>;
|
|
85
|
+
removeZshrcConfig(zshrcPath?: string): Promise<boolean>;
|
|
78
86
|
/**
|
|
79
87
|
* Install the completion script
|
|
80
88
|
*
|
|
@@ -3,6 +3,23 @@ import path from 'path';
|
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import { FileSystemUtils } from '../../../utils/file-system.js';
|
|
5
5
|
import { shellSingleQuote } from './shell-quote.js';
|
|
6
|
+
/**
|
|
7
|
+
* zsh reads its startup files from $ZDOTDIR when it is set, and from $HOME
|
|
8
|
+
* only otherwise, so a block written to ~/.zshrc under an XDG-style
|
|
9
|
+
* ZDOTDIR is never loaded (#2138).
|
|
10
|
+
*/
|
|
11
|
+
function zshrcDir(homeDir) {
|
|
12
|
+
const zdotdir = process.env.ZDOTDIR;
|
|
13
|
+
// zsh does not expand `~` in ZDOTDIR, and path.join would resolve a relative
|
|
14
|
+
// or `~/...` value against the current directory, writing a stray .zshrc
|
|
15
|
+
// into the user's project. Only an absolute ZDOTDIR moves the file.
|
|
16
|
+
return zdotdir && path.isAbsolute(zdotdir) ? zdotdir : homeDir;
|
|
17
|
+
}
|
|
18
|
+
/** The .zshrc zsh will read, as shown to the user: `~/.zshrc` unless ZDOTDIR moves it. */
|
|
19
|
+
export function zshrcDisplayPath(homeDir = os.homedir()) {
|
|
20
|
+
const zshrcPath = path.join(zshrcDir(homeDir), '.zshrc');
|
|
21
|
+
return zshrcPath === path.join(homeDir, '.zshrc') ? '~/.zshrc' : zshrcPath;
|
|
22
|
+
}
|
|
6
23
|
/**
|
|
7
24
|
* Installer for Zsh completion scripts.
|
|
8
25
|
* Supports both Oh My Zsh and standard Zsh configurations.
|
|
@@ -99,7 +116,14 @@ export class ZshInstaller {
|
|
|
99
116
|
* @returns Path to .zshrc
|
|
100
117
|
*/
|
|
101
118
|
getZshrcPath() {
|
|
102
|
-
return path.join(this.homeDir, '.zshrc');
|
|
119
|
+
return path.join(zshrcDir(this.homeDir), '.zshrc');
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Every .zshrc that may hold an OpenSpec block: the one zsh reads, plus
|
|
123
|
+
* ~/.zshrc, where installs before #2138 wrote it even under ZDOTDIR.
|
|
124
|
+
*/
|
|
125
|
+
getZshrcCandidates() {
|
|
126
|
+
return [...new Set([this.getZshrcPath(), path.join(this.homeDir, '.zshrc')])];
|
|
103
127
|
}
|
|
104
128
|
/**
|
|
105
129
|
* Generate .zshrc configuration content
|
|
@@ -150,9 +174,8 @@ export class ZshInstaller {
|
|
|
150
174
|
*
|
|
151
175
|
* @returns true if .zshrc exists and has markers
|
|
152
176
|
*/
|
|
153
|
-
async hasZshrcConfig() {
|
|
177
|
+
async hasZshrcConfig(zshrcPath = this.getZshrcPath()) {
|
|
154
178
|
try {
|
|
155
|
-
const zshrcPath = this.getZshrcPath();
|
|
156
179
|
const content = await fs.readFile(zshrcPath, 'utf-8');
|
|
157
180
|
return content.includes(this.ZSHRC_MARKERS.start) && content.includes(this.ZSHRC_MARKERS.end);
|
|
158
181
|
}
|
|
@@ -164,11 +187,11 @@ export class ZshInstaller {
|
|
|
164
187
|
* Remove .zshrc configuration
|
|
165
188
|
* Used during uninstallation
|
|
166
189
|
*
|
|
190
|
+
* @param zshrcPath - The .zshrc to clean; defaults to the one zsh reads
|
|
167
191
|
* @returns true if removed successfully, false otherwise
|
|
168
192
|
*/
|
|
169
|
-
async removeZshrcConfig() {
|
|
193
|
+
async removeZshrcConfig(zshrcPath = this.getZshrcPath()) {
|
|
170
194
|
try {
|
|
171
|
-
const zshrcPath = this.getZshrcPath();
|
|
172
195
|
// Check if file exists
|
|
173
196
|
try {
|
|
174
197
|
await fs.access(zshrcPath);
|
|
@@ -224,16 +247,24 @@ export class ZshInstaller {
|
|
|
224
247
|
try {
|
|
225
248
|
const existingContent = await fs.readFile(targetPath, 'utf-8');
|
|
226
249
|
if (existingContent === completionScript) {
|
|
227
|
-
// Already installed and up to date
|
|
250
|
+
// Already installed and up to date. Still make sure the .zshrc zsh
|
|
251
|
+
// reads has the block: an install made before #2138 under ZDOTDIR
|
|
252
|
+
// wrote it to ~/.zshrc, and re-running install is how that's fixed.
|
|
253
|
+
const zshrcConfigured = isOhMyZsh
|
|
254
|
+
? false
|
|
255
|
+
: await this.configureZshrc(path.dirname(targetPath));
|
|
228
256
|
return {
|
|
229
257
|
success: true,
|
|
230
258
|
installedPath: targetPath,
|
|
231
259
|
isOhMyZsh,
|
|
260
|
+
zshrcConfigured,
|
|
232
261
|
message: 'Completion script is already installed (up to date)',
|
|
233
|
-
instructions:
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
262
|
+
instructions: isOhMyZsh || zshrcConfigured
|
|
263
|
+
? [
|
|
264
|
+
'The completion script is already installed and up to date.',
|
|
265
|
+
'If completions are not working, try: exec zsh',
|
|
266
|
+
]
|
|
267
|
+
: this.generateInstructions(false, targetPath),
|
|
237
268
|
};
|
|
238
269
|
}
|
|
239
270
|
// File exists but content is different - this is an update
|
|
@@ -336,11 +367,11 @@ export class ZshInstaller {
|
|
|
336
367
|
}
|
|
337
368
|
else {
|
|
338
369
|
const completionsDir = path.dirname(installedPath);
|
|
339
|
-
const zshrcPath =
|
|
370
|
+
const zshrcPath = this.getZshrcPath();
|
|
340
371
|
return [
|
|
341
372
|
'Completion script installed to ~/.zsh/completions/',
|
|
342
373
|
'',
|
|
343
|
-
|
|
374
|
+
`To enable completions, add the following to your ${zshrcDisplayPath(this.homeDir)} file:`,
|
|
344
375
|
'',
|
|
345
376
|
` # Add completions directory to fpath`,
|
|
346
377
|
` fpath=(${shellSingleQuote(completionsDir)} $fpath)`,
|
|
@@ -375,11 +406,19 @@ export class ZshInstaller {
|
|
|
375
406
|
}
|
|
376
407
|
// Try to remove .zshrc configuration (only for standard Zsh)
|
|
377
408
|
let zshrcWasPresent = false;
|
|
378
|
-
|
|
409
|
+
const cleanedZshrcs = [];
|
|
410
|
+
const failedZshrcs = [];
|
|
379
411
|
if (!isOhMyZsh) {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
412
|
+
for (const zshrcPath of this.getZshrcCandidates()) {
|
|
413
|
+
if (await this.hasZshrcConfig(zshrcPath)) {
|
|
414
|
+
zshrcWasPresent = true;
|
|
415
|
+
if (await this.removeZshrcConfig(zshrcPath)) {
|
|
416
|
+
cleanedZshrcs.push(zshrcPath);
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
failedZshrcs.push(zshrcPath);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
383
422
|
}
|
|
384
423
|
}
|
|
385
424
|
if (!scriptRemoved && !zshrcWasPresent) {
|
|
@@ -392,11 +431,17 @@ export class ZshInstaller {
|
|
|
392
431
|
if (scriptRemoved) {
|
|
393
432
|
messages.push(`Completion script removed from ${targetPath}`);
|
|
394
433
|
}
|
|
395
|
-
|
|
396
|
-
|
|
434
|
+
const label = (zshrcPath) => zshrcPath === path.join(this.homeDir, '.zshrc') ? '~/.zshrc' : zshrcPath;
|
|
435
|
+
for (const zshrcPath of cleanedZshrcs) {
|
|
436
|
+
messages.push(`Removed OpenSpec configuration from ${label(zshrcPath)}`);
|
|
437
|
+
}
|
|
438
|
+
// A block left behind keeps loading the completions, so say so instead
|
|
439
|
+
// of reporting a clean uninstall.
|
|
440
|
+
for (const zshrcPath of failedZshrcs) {
|
|
441
|
+
messages.push(`Could not remove the OpenSpec block from ${label(zshrcPath)}; delete the lines between # OPENSPEC:START and # OPENSPEC:END by hand`);
|
|
397
442
|
}
|
|
398
443
|
return {
|
|
399
|
-
success:
|
|
444
|
+
success: failedZshrcs.length === 0,
|
|
400
445
|
message: messages.join('. '),
|
|
401
446
|
};
|
|
402
447
|
}
|
|
@@ -20,6 +20,7 @@ export declare const GlobalConfigSchema: z.ZodObject<{
|
|
|
20
20
|
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
21
21
|
}, z.core.$loose>>;
|
|
22
22
|
completionTipSeen: z.ZodOptional<z.ZodBoolean>;
|
|
23
|
+
standaloneTipSeen: z.ZodOptional<z.ZodBoolean>;
|
|
23
24
|
}, z.core.$loose>;
|
|
24
25
|
export type GlobalConfigType = z.infer<typeof GlobalConfigSchema>;
|
|
25
26
|
/**
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a check reads the files it inspects. Normally the disk. `update
|
|
3
|
+
* --dry-run` passes the tree as it has planned it, so a step that reads what
|
|
4
|
+
* an earlier step wrote sees the same thing it would in a real update.
|
|
5
|
+
*/
|
|
6
|
+
export interface FileView {
|
|
7
|
+
exists(filePath: string): boolean;
|
|
8
|
+
/** Throws, like `fs.readFileSync`, when the file is absent. */
|
|
9
|
+
read(filePath: string): string;
|
|
10
|
+
isDirectory(filePath: string): boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare const DISK_VIEW: FileView;
|
|
13
|
+
//# sourceMappingURL=file-view.d.ts.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
export const DISK_VIEW = {
|
|
3
|
+
exists: (filePath) => fs.existsSync(filePath),
|
|
4
|
+
read: (filePath) => fs.readFileSync(filePath, 'utf-8'),
|
|
5
|
+
isDirectory: (filePath) => {
|
|
6
|
+
try {
|
|
7
|
+
return fs.statSync(filePath).isDirectory();
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
//# sourceMappingURL=file-view.js.map
|
|
@@ -28,6 +28,14 @@ export declare const COPILOT_CLOUD_FILES: {
|
|
|
28
28
|
readonly setupSteps: string;
|
|
29
29
|
readonly agent: string;
|
|
30
30
|
};
|
|
31
|
+
/**
|
|
32
|
+
* Every change the cloud-file sync makes on disk. `update --dry-run` passes a
|
|
33
|
+
* recorder, so the plan comes from this same code rather than a description.
|
|
34
|
+
*/
|
|
35
|
+
export interface CopilotCloudFileOps {
|
|
36
|
+
writeFile(filePath: string, content: string): Promise<void>;
|
|
37
|
+
removeFile(filePath: string): Promise<void>;
|
|
38
|
+
}
|
|
31
39
|
/**
|
|
32
40
|
* Reconcile Copilot cloud agent files in the project directory.
|
|
33
41
|
* Creates missing files and refreshes recognized legacy generated files while
|
|
@@ -35,7 +43,7 @@ export declare const COPILOT_CLOUD_FILES: {
|
|
|
35
43
|
*
|
|
36
44
|
* @returns Object indicating which files were written.
|
|
37
45
|
*/
|
|
38
|
-
export declare function writeCopilotCloudFiles(projectPath: string): Promise<{
|
|
46
|
+
export declare function writeCopilotCloudFiles(projectPath: string, ops?: CopilotCloudFileOps): Promise<{
|
|
39
47
|
setupStepsWritten: boolean;
|
|
40
48
|
agentWritten: boolean;
|
|
41
49
|
}>;
|
|
@@ -45,7 +53,7 @@ export declare function writeCopilotCloudFiles(projectPath: string): Promise<{
|
|
|
45
53
|
*
|
|
46
54
|
* @returns Number of files removed.
|
|
47
55
|
*/
|
|
48
|
-
export declare function removeCopilotCloudFiles(projectPath: string): Promise<number>;
|
|
56
|
+
export declare function removeCopilotCloudFiles(projectPath: string, ops?: CopilotCloudFileOps): Promise<number>;
|
|
49
57
|
/**
|
|
50
58
|
* Read the persisted opt-in for Copilot cloud-file generation.
|
|
51
59
|
*
|
|
@@ -242,10 +242,14 @@ function isLegacyCopilotCloudFile(relPath, content) {
|
|
|
242
242
|
function isManagedCopilotCloudFile(relPath, content) {
|
|
243
243
|
return isCurrentCopilotCloudFile(relPath, content) || isLegacyCopilotCloudFile(relPath, content);
|
|
244
244
|
}
|
|
245
|
-
|
|
245
|
+
const DIRECT_CLOUD_FILE_OPS = {
|
|
246
|
+
writeFile: (filePath, content) => FileSystemUtils.writeFile(filePath, content),
|
|
247
|
+
removeFile: (filePath) => fs.unlink(filePath),
|
|
248
|
+
};
|
|
249
|
+
async function reconcileCopilotCloudFile(fullPath, relPath, ops) {
|
|
246
250
|
const currentContent = COPILOT_CLOUD_FILE_CONTENTS[relPath];
|
|
247
251
|
if (!(await FileSystemUtils.fileExists(fullPath))) {
|
|
248
|
-
await
|
|
252
|
+
await ops.writeFile(fullPath, currentContent);
|
|
249
253
|
return true;
|
|
250
254
|
}
|
|
251
255
|
const existingContent = await FileSystemUtils.readFile(fullPath);
|
|
@@ -255,7 +259,7 @@ async function reconcileCopilotCloudFile(fullPath, relPath) {
|
|
|
255
259
|
if (!isLegacyCopilotCloudFile(relPath, existingContent)) {
|
|
256
260
|
return false;
|
|
257
261
|
}
|
|
258
|
-
await
|
|
262
|
+
await ops.writeFile(fullPath, currentContent);
|
|
259
263
|
return true;
|
|
260
264
|
}
|
|
261
265
|
async function assertCreatableFilePath(filePath) {
|
|
@@ -313,7 +317,7 @@ async function classifyCopilotAgentReconciliation(agentPath, alternateAgentPath)
|
|
|
313
317
|
*
|
|
314
318
|
* @returns Object indicating which files were written.
|
|
315
319
|
*/
|
|
316
|
-
export async function writeCopilotCloudFiles(projectPath) {
|
|
320
|
+
export async function writeCopilotCloudFiles(projectPath, ops = DIRECT_CLOUD_FILE_OPS) {
|
|
317
321
|
const setupStepsPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, COPILOT_CLOUD_FILES.setupSteps);
|
|
318
322
|
const agentPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, COPILOT_CLOUD_FILES.agent);
|
|
319
323
|
const alternateAgentPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, COPILOT_AGENT_ALTERNATE_FILE);
|
|
@@ -323,13 +327,13 @@ export async function writeCopilotCloudFiles(projectPath) {
|
|
|
323
327
|
await assertMissingOrRegularFile(agentPath);
|
|
324
328
|
await assertMissingOrRegularFile(alternateAgentPath);
|
|
325
329
|
const agentReconciliation = await classifyCopilotAgentReconciliation(agentPath, alternateAgentPath);
|
|
326
|
-
const setupStepsWritten = await reconcileCopilotCloudFile(setupStepsPath, COPILOT_CLOUD_FILES.setupSteps);
|
|
330
|
+
const setupStepsWritten = await reconcileCopilotCloudFile(setupStepsPath, COPILOT_CLOUD_FILES.setupSteps, ops);
|
|
327
331
|
let agentWritten = false;
|
|
328
332
|
if (agentReconciliation === 'reconcile') {
|
|
329
|
-
agentWritten = await reconcileCopilotCloudFile(agentPath, COPILOT_CLOUD_FILES.agent);
|
|
333
|
+
agentWritten = await reconcileCopilotCloudFile(agentPath, COPILOT_CLOUD_FILES.agent, ops);
|
|
330
334
|
}
|
|
331
335
|
else if (agentReconciliation === 'remove-managed') {
|
|
332
|
-
await
|
|
336
|
+
await ops.removeFile(agentPath);
|
|
333
337
|
}
|
|
334
338
|
return { setupStepsWritten, agentWritten };
|
|
335
339
|
}
|
|
@@ -339,7 +343,7 @@ export async function writeCopilotCloudFiles(projectPath) {
|
|
|
339
343
|
*
|
|
340
344
|
* @returns Number of files removed.
|
|
341
345
|
*/
|
|
342
|
-
export async function removeCopilotCloudFiles(projectPath) {
|
|
346
|
+
export async function removeCopilotCloudFiles(projectPath, ops = DIRECT_CLOUD_FILE_OPS) {
|
|
343
347
|
let removed = 0;
|
|
344
348
|
const managedPaths = Object.values(COPILOT_CLOUD_FILES).map((relPath) => ({
|
|
345
349
|
relPath,
|
|
@@ -354,7 +358,7 @@ export async function removeCopilotCloudFiles(projectPath) {
|
|
|
354
358
|
if (!isManagedCopilotCloudFile(relPath, content)) {
|
|
355
359
|
continue;
|
|
356
360
|
}
|
|
357
|
-
await
|
|
361
|
+
await ops.removeFile(fullPath);
|
|
358
362
|
removed++;
|
|
359
363
|
}
|
|
360
364
|
}
|
|
@@ -28,6 +28,8 @@ export interface GlobalConfig {
|
|
|
28
28
|
telemetry?: TelemetryConfig;
|
|
29
29
|
/** Whether the first-run shell-completions tip has been shown. */
|
|
30
30
|
completionTipSeen?: boolean;
|
|
31
|
+
/** Whether the one-time note about the standalone install has been shown. */
|
|
32
|
+
standaloneTipSeen?: boolean;
|
|
31
33
|
}
|
|
32
34
|
/**
|
|
33
35
|
* Gets the global configuration directory path following XDG Base Directory Specification.
|
|
@@ -94,4 +96,6 @@ export interface SaveGlobalConfigOptions {
|
|
|
94
96
|
* existing file it cannot parse unless `replaceUnreadable` is set.
|
|
95
97
|
*/
|
|
96
98
|
export declare function saveGlobalConfig(config: GlobalConfig, options?: SaveGlobalConfigOptions): void;
|
|
99
|
+
/** The bytes `saveGlobalConfig` writes, for a caller that only plans the write. */
|
|
100
|
+
export declare function serializeGlobalConfig(config: GlobalConfig): string;
|
|
97
101
|
//# sourceMappingURL=global-config.d.ts.map
|
|
@@ -173,6 +173,10 @@ export function saveGlobalConfig(config, options = {}) {
|
|
|
173
173
|
if (!fs.existsSync(configDir)) {
|
|
174
174
|
fs.mkdirSync(configDir, { recursive: true });
|
|
175
175
|
}
|
|
176
|
-
fs.writeFileSync(configPath,
|
|
176
|
+
fs.writeFileSync(configPath, serializeGlobalConfig(config), 'utf-8');
|
|
177
|
+
}
|
|
178
|
+
/** The bytes `saveGlobalConfig` writes, for a caller that only plans the write. */
|
|
179
|
+
export function serializeGlobalConfig(config) {
|
|
180
|
+
return JSON.stringify(config, null, 2) + '\n';
|
|
177
181
|
}
|
|
178
182
|
//# sourceMappingURL=global-config.js.map
|
package/dist/core/id.d.ts
CHANGED
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export declare const KEBAB_ID_REGEX: RegExp;
|
|
6
6
|
export declare function isKebabId(value: string): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Best-effort normalization of an arbitrary name into the kebab grammar:
|
|
9
|
+
* lowercases, splits camelCase/PascalCase boundaries, and folds every
|
|
10
|
+
* non-alphanumeric run into a single hyphen. Used to turn a git repository
|
|
11
|
+
* name into a candidate store id; the result is only ever a candidate and
|
|
12
|
+
* is validated with {@link isKebabId} before use.
|
|
13
|
+
*/
|
|
14
|
+
export declare function toKebabCase(value: string): string;
|
|
7
15
|
/** Human rendering of the grammar, shared so the wording never forks. */
|
|
8
16
|
export declare const KEBAB_ID_DESCRIPTION = "must be kebab-case with lowercase letters, numbers, and single hyphen separators";
|
|
9
17
|
/** The fix-line twin of KEBAB_ID_DESCRIPTION, shared for the same reason. */
|