@fission-ai/openspec 1.14.3 → 1.15.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 +13 -9
- package/dist/cli/index.js +47 -0
- 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 +10 -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/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/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/profile-sync-drift.d.ts +4 -3
- package/dist/core/profile-sync-drift.js +19 -16
- 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/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/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 +4 -3
|
@@ -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',
|
|
@@ -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
|
|
@@ -166,7 +166,17 @@ export interface CleanupResult {
|
|
|
166
166
|
* @param detection - Detection result from detectLegacyArtifacts
|
|
167
167
|
* @returns Cleanup result with summary of actions taken
|
|
168
168
|
*/
|
|
169
|
-
|
|
169
|
+
/**
|
|
170
|
+
* Every change cleanup makes on disk. `update --dry-run` passes a recorder, so
|
|
171
|
+
* the plan comes from this same code rather than a description of it.
|
|
172
|
+
*/
|
|
173
|
+
export interface LegacyCleanupOps {
|
|
174
|
+
writeFile(filePath: string, content: string): Promise<void>;
|
|
175
|
+
removeFile(filePath: string): Promise<void>;
|
|
176
|
+
/** Removes a directory already known to be empty. */
|
|
177
|
+
removeEmptyDirectory(dirPath: string): Promise<void>;
|
|
178
|
+
}
|
|
179
|
+
export declare function cleanupLegacyArtifacts(projectPath: string, detection: LegacyDetectionResult, ops?: LegacyCleanupOps): Promise<CleanupResult>;
|
|
170
180
|
/**
|
|
171
181
|
* Generates a cleanup summary message for display.
|
|
172
182
|
*
|
|
@@ -323,14 +323,14 @@ function legacyCommandDirForFile(file) {
|
|
|
323
323
|
* or records what is left in it as kept. Never recursive: whatever remains was
|
|
324
324
|
* not written by OpenSpec. Returns true when the directory was removed.
|
|
325
325
|
*/
|
|
326
|
-
async function settleLegacyCommandDir(projectPath, dirPath, result) {
|
|
326
|
+
async function settleLegacyCommandDir(projectPath, dirPath, result, ops = DIRECT_CLEANUP_OPS) {
|
|
327
327
|
const fullPath = FileSystemUtils.joinPath(projectPath, dirPath);
|
|
328
328
|
const remaining = await readLegacyCommandDir(fullPath, []);
|
|
329
329
|
if (!remaining) {
|
|
330
330
|
return false;
|
|
331
331
|
}
|
|
332
332
|
if (remaining.others.length === 0) {
|
|
333
|
-
await
|
|
333
|
+
await ops.removeEmptyDirectory(fullPath);
|
|
334
334
|
result.deletedDirs.push(dirPath);
|
|
335
335
|
return true;
|
|
336
336
|
}
|
|
@@ -493,15 +493,12 @@ export function isOnlyOpenSpecContent(content) {
|
|
|
493
493
|
export function removeMarkerBlock(content) {
|
|
494
494
|
return removeMarkerBlockUtil(content, OPENSPEC_MARKERS.start, OPENSPEC_MARKERS.end);
|
|
495
495
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
* @returns Cleanup result with summary of actions taken
|
|
503
|
-
*/
|
|
504
|
-
export async function cleanupLegacyArtifacts(projectPath, detection) {
|
|
496
|
+
const DIRECT_CLEANUP_OPS = {
|
|
497
|
+
writeFile: (filePath, content) => FileSystemUtils.writeFile(filePath, content),
|
|
498
|
+
removeFile: (filePath) => fs.unlink(filePath),
|
|
499
|
+
removeEmptyDirectory: (dirPath) => fs.rmdir(dirPath),
|
|
500
|
+
};
|
|
501
|
+
export async function cleanupLegacyArtifacts(projectPath, detection, ops = DIRECT_CLEANUP_OPS) {
|
|
505
502
|
const result = {
|
|
506
503
|
deletedFiles: [],
|
|
507
504
|
deletedFileReplacementLabels: {},
|
|
@@ -519,7 +516,7 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
|
|
|
519
516
|
const content = await FileSystemUtils.readFile(filePath);
|
|
520
517
|
const newContent = removeMarkerBlock(content);
|
|
521
518
|
// Always write the file, even if empty - never delete user config files
|
|
522
|
-
await
|
|
519
|
+
await ops.writeFile(filePath, newContent);
|
|
523
520
|
result.modifiedFiles.push(fileName);
|
|
524
521
|
}
|
|
525
522
|
catch (error) {
|
|
@@ -545,10 +542,10 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
|
|
|
545
542
|
if (!(await isGeneratedLegacyCommand(filePath))) {
|
|
546
543
|
continue;
|
|
547
544
|
}
|
|
548
|
-
await
|
|
545
|
+
await ops.removeFile(filePath);
|
|
549
546
|
deleted.push(name);
|
|
550
547
|
}
|
|
551
|
-
if (!(await settleLegacyCommandDir(projectPath, dirPath, result))) {
|
|
548
|
+
if (!(await settleLegacyCommandDir(projectPath, dirPath, result, ops))) {
|
|
552
549
|
result.deletedFiles.push(...deleted.map((name) => `${dirPath}/${name}`));
|
|
553
550
|
}
|
|
554
551
|
}
|
|
@@ -570,7 +567,7 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
|
|
|
570
567
|
continue;
|
|
571
568
|
}
|
|
572
569
|
}
|
|
573
|
-
await
|
|
570
|
+
await ops.removeFile(fullPath);
|
|
574
571
|
result.deletedFiles.push(filePath);
|
|
575
572
|
}
|
|
576
573
|
catch (error) {
|
|
@@ -581,7 +578,7 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
|
|
|
581
578
|
// file by file above; record what was left in it.
|
|
582
579
|
for (const dirPath of partlyCleanedDirs) {
|
|
583
580
|
try {
|
|
584
|
-
await settleLegacyCommandDir(projectPath, dirPath, result);
|
|
581
|
+
await settleLegacyCommandDir(projectPath, dirPath, result, ops);
|
|
585
582
|
}
|
|
586
583
|
catch (error) {
|
|
587
584
|
result.errors.push(`Failed to delete directory ${dirPath}: ${error.message}`);
|
|
@@ -595,7 +592,7 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
|
|
|
595
592
|
continue;
|
|
596
593
|
}
|
|
597
594
|
try {
|
|
598
|
-
await
|
|
595
|
+
await ops.removeFile(filePath);
|
|
599
596
|
result.deletedFiles.push(filePath);
|
|
600
597
|
const promptMatch = globalPromptMatchesByPath.get(filePath);
|
|
601
598
|
if (promptMatch?.replacementLabel) {
|
|
@@ -611,7 +608,7 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
|
|
|
611
608
|
const agentsPath = FileSystemUtils.joinPath(projectPath, 'openspec', 'AGENTS.md');
|
|
612
609
|
if (await FileSystemUtils.fileExists(agentsPath)) {
|
|
613
610
|
try {
|
|
614
|
-
await
|
|
611
|
+
await ops.removeFile(agentsPath);
|
|
615
612
|
result.deletedFiles.push('openspec/AGENTS.md');
|
|
616
613
|
}
|
|
617
614
|
catch (error) {
|
package/dist/core/migration.d.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* Called by both init and update commands before profile resolution.
|
|
6
6
|
*/
|
|
7
7
|
import { type AIToolOption } from './config.js';
|
|
8
|
+
import { type Delivery, type GlobalConfig, type Profile } from './global-config.js';
|
|
9
|
+
import { type FileView } from './file-view.js';
|
|
8
10
|
export interface LegacyToolRoot {
|
|
9
11
|
/** Former tool root, e.g. '.kimi' */
|
|
10
12
|
root: string;
|
|
@@ -41,11 +43,27 @@ export interface LegacyToolMigration {
|
|
|
41
43
|
/** Whether this move needs the user's consent first */
|
|
42
44
|
needsConsent: boolean;
|
|
43
45
|
}
|
|
46
|
+
/** One file a legacy-root migration would move or drop. */
|
|
47
|
+
export interface LegacyMigrationFileOp {
|
|
48
|
+
toolId: string;
|
|
49
|
+
source: string;
|
|
50
|
+
destination: string;
|
|
51
|
+
disposition: 'move' | 'drop';
|
|
52
|
+
/** Whether an interactive run asks before this move */
|
|
53
|
+
needsConsent: boolean;
|
|
54
|
+
}
|
|
44
55
|
/**
|
|
45
56
|
* Reports the OpenSpec content sitting under each tool's legacy root, without
|
|
46
57
|
* moving anything. Callers use this to ask before a move that needs consent.
|
|
47
58
|
*/
|
|
48
59
|
export declare function findLegacyToolMigrations(projectPath: string, timing?: 'before-generation' | 'after-generation'): LegacyToolMigration[];
|
|
60
|
+
/**
|
|
61
|
+
* Lists, file by file, what `migrateLegacyToolDirs` would move or drop, without
|
|
62
|
+
* touching anything. Consent-gated roots are included: a non-interactive run
|
|
63
|
+
* moves them without asking. `view` decides what already sits at each
|
|
64
|
+
* destination, so a dry run can plan against files it has not written.
|
|
65
|
+
*/
|
|
66
|
+
export declare function planLegacyToolMigrations(projectPath: string, timing: 'before-generation' | 'after-generation', toolIds: string[] | undefined, view: FileView): LegacyMigrationFileOp[];
|
|
49
67
|
/**
|
|
50
68
|
* Moves OpenSpec-managed skill directories (openspec-*) and command files
|
|
51
69
|
* (opsx-*) from a tool's legacy root to its current one. When the destination
|
|
@@ -81,7 +99,7 @@ export declare function legacyMigrationNotice(migration: LegacyToolMigration): s
|
|
|
81
99
|
* Scans installed workflow files across all detected tools and returns
|
|
82
100
|
* the union of installed workflow IDs.
|
|
83
101
|
*/
|
|
84
|
-
export declare function scanInstalledWorkflows(projectPath: string, tools: AIToolOption[]): string[];
|
|
102
|
+
export declare function scanInstalledWorkflows(projectPath: string, tools: AIToolOption[], view?: FileView): string[];
|
|
85
103
|
/**
|
|
86
104
|
* Performs one-time migration if the global config does not yet have a profile field.
|
|
87
105
|
* Called by both init and update before profile resolution.
|
|
@@ -91,5 +109,13 @@ export declare function scanInstalledWorkflows(projectPath: string, tools: AIToo
|
|
|
91
109
|
* - If no profile field exists and no workflows are installed: no-op (defaults apply).
|
|
92
110
|
* - If profile field already exists: no-op.
|
|
93
111
|
*/
|
|
112
|
+
export declare function computeProfileMigration(projectPath: string, tools: AIToolOption[], view?: FileView): ProfileMigration | null;
|
|
113
|
+
export type ProfileMigration = {
|
|
114
|
+
profile: Profile;
|
|
115
|
+
workflows: string[];
|
|
116
|
+
delivery?: Delivery;
|
|
117
|
+
};
|
|
118
|
+
/** The global config the one-time profile migration saves. */
|
|
119
|
+
export declare function applyProfileMigration(migration: ProfileMigration): GlobalConfig;
|
|
94
120
|
export declare function migrateIfNeeded(projectPath: string, tools: AIToolOption[]): void;
|
|
95
121
|
//# sourceMappingURL=migration.d.ts.map
|