magus-cli 7.5.3 → 7.6.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/package.json +4 -4
- package/src/__tests__/blackbox-convergence.test.ts +1 -2
- package/src/__tests__/blackbox-parity.test.ts +16 -14
- package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
- package/src/__tests__/cli-apply-seams.test.ts +3 -3
- package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
- package/src/__tests__/content-drift.test.ts +0 -1
- package/src/__tests__/files-missing-scope.test.ts +235 -0
- package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
- package/src/__tests__/format-update-outcome.test.ts +367 -0
- package/src/__tests__/installed-version-scope.test.tsx +425 -0
- package/src/__tests__/key-focus-guard.test.tsx +260 -0
- package/src/__tests__/mate-availability.test.ts +11 -13
- package/src/__tests__/plugin-contents.test.ts +0 -1
- package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
- package/src/__tests__/plugin-row-text.test.ts +18 -21
- package/src/__tests__/profile-command.test.ts +2 -2
- package/src/__tests__/profile-migration-offer.test.ts +5 -4
- package/src/__tests__/profile-name-on-save.test.ts +443 -0
- package/src/__tests__/profile-skills.test.ts +3 -3
- package/src/__tests__/profile-startup-nested.test.ts +329 -0
- package/src/__tests__/profile-startup-old-format.test.ts +390 -0
- package/src/__tests__/profile-startup.test.ts +1 -1
- package/src/__tests__/profile-state.test.ts +27 -22
- package/src/__tests__/prompt-eof.test.ts +245 -0
- package/src/__tests__/scope-action.test.ts +35 -38
- package/src/__tests__/scope-row-lookup.test.ts +126 -0
- package/src/__tests__/select-restore.test.ts +209 -0
- package/src/__tests__/stale-load-repair.test.ts +1 -2
- package/src/__tests__/update-apply.test.ts +18 -2
- package/src/__tests__/update-null-readback.test.ts +300 -0
- package/src/__tests__/update-plan-parity.test.ts +249 -0
- package/src/__tests__/update-plan.test.ts +66 -67
- package/src/__tests__/version-unknown-state.test.ts +38 -39
- package/src/cli/bootstrap.ts +271 -66
- package/src/cli/doctor.ts +4 -3
- package/src/cli/install.ts +83 -27
- package/src/cli/prompt.ts +19 -5
- package/src/cli/router.ts +1 -0
- package/src/cli/select.ts +168 -0
- package/src/cli/update-view.ts +120 -6
- package/src/cli/update.ts +6 -4
- package/src/services/claude-settings.ts +28 -0
- package/src/services/manifest.ts +31 -0
- package/src/services/mate-availability.ts +23 -29
- package/src/services/plugin-manager.ts +247 -105
- package/src/services/profile-absorb.ts +10 -7
- package/src/services/profile-activate.ts +41 -19
- package/src/services/profile-adopt.ts +9 -2
- package/src/services/profile-startup.ts +206 -5
- package/src/services/profile-state.ts +8 -7
- package/src/services/tracked-live-files.ts +24 -3
- package/src/services/update-engine.ts +28 -10
- package/src/services/update-plan.ts +78 -94
- package/src/ui/App.tsx +96 -56
- package/src/ui/adapters/pluginsAdapter.ts +1 -1
- package/src/ui/components/modals/ModalContainer.tsx +3 -0
- package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
- package/src/ui/hooks/useGitignoreModal.ts +21 -3
- package/src/ui/hooks/useProfileStartupModal.ts +214 -15
- package/src/ui/hooks/useStaleLoadModal.ts +5 -1
- package/src/ui/input/KeyRouter.tsx +43 -2
- package/src/ui/keymap.ts +1 -1
- package/src/ui/renderers/pluginRenderers.tsx +39 -45
- package/src/ui/renderers/pluginRowText.ts +6 -2
- package/src/ui/renderers/pluginStatusText.ts +4 -3
- package/src/ui/screens/PluginsScreen.tsx +28 -41
- package/src/cli/tui-prompt.tsx +0 -143
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Save what you have" asks for a name — written blind from the spec's public
|
|
3
|
+
* contract, never from the implementation.
|
|
4
|
+
*
|
|
5
|
+
* Requirements (from the spec):
|
|
6
|
+
* REQ-1 The save row's label carries no generated id; it is exactly
|
|
7
|
+
* `Save what you have as a new profile`.
|
|
8
|
+
* REQ-3 ONE pure function validates the typed name; an invalid name is a
|
|
9
|
+
* one-line reason, never silently changed to another id.
|
|
10
|
+
* REQ-4 The new profile is written under the derived id with `name` = the
|
|
11
|
+
* trimmed text (via applyProfileChoice's adopt `name`).
|
|
12
|
+
* REQ-6 Applies to both states that offer the save row: inactive, unmigrated.
|
|
13
|
+
* C-1 profileIdForName: empty/whitespace → "Enter a name for the profile."
|
|
14
|
+
* C-2 id = lowercase, every run outside [a-z0-9] → one "-", edges trimmed.
|
|
15
|
+
* C-3 empty id → "Use at least one letter or digit."
|
|
16
|
+
* C-4 id already a key → 'A profile called "<name or id>" already exists.
|
|
17
|
+
* Choose another name.'
|
|
18
|
+
* C-5 otherwise { ok: true, id, name } with name trimmed.
|
|
19
|
+
* C-6 applyProfileChoice adopt without `name` keeps current behaviour.
|
|
20
|
+
*
|
|
21
|
+
* REQ-2 and REQ-5 (the prompt itself, its suggestion default, and cancelling
|
|
22
|
+
* it) live in the CLI/TUI surfaces, which the contract does not expose; they
|
|
23
|
+
* are listed as known gaps in the run report.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
27
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
28
|
+
import { tmpdir } from "node:os";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
import fs from "fs-extra";
|
|
31
|
+
import { readManifest, writeManifest } from "../services/manifest.js";
|
|
32
|
+
import type { ProfileChoice } from "../services/profile-activate.js";
|
|
33
|
+
import {
|
|
34
|
+
ADOPT_VALUE,
|
|
35
|
+
buildProfileOptions,
|
|
36
|
+
detectProfileStartupNeed,
|
|
37
|
+
parseProfileChoice,
|
|
38
|
+
profileIdForName,
|
|
39
|
+
} from "../services/profile-startup.js";
|
|
40
|
+
import { applyProfileChoice } from "../services/profile-state.js";
|
|
41
|
+
import type { ProfileManifest } from "../types/index.js";
|
|
42
|
+
|
|
43
|
+
const SAVE_LABEL = "Save what you have as a new profile";
|
|
44
|
+
|
|
45
|
+
const MANIFEST: ProfileManifest = {
|
|
46
|
+
version: 2,
|
|
47
|
+
profiles: {
|
|
48
|
+
lean: { name: "Lean", plugins: { "terminal@magus": "latest" } },
|
|
49
|
+
full: {
|
|
50
|
+
name: "Full Stack",
|
|
51
|
+
plugins: { "terminal@magus": "latest", "dev@magus": "latest" },
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** A manifest with a profile that carries no display name. */
|
|
57
|
+
const NAMELESS = {
|
|
58
|
+
version: 2,
|
|
59
|
+
profiles: {
|
|
60
|
+
default: { plugins: { "terminal@magus": "latest" } },
|
|
61
|
+
},
|
|
62
|
+
} as unknown as ProfileManifest;
|
|
63
|
+
|
|
64
|
+
const EMPTY: ProfileManifest = { version: 2, profiles: {} };
|
|
65
|
+
|
|
66
|
+
describe("profileIdForName — empty input (C-1)", () => {
|
|
67
|
+
test("C-1: an empty string asks for a name", () => {
|
|
68
|
+
expect(profileIdForName("", MANIFEST)).toEqual({
|
|
69
|
+
ok: false,
|
|
70
|
+
reason: "Enter a name for the profile.",
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("C-1: whitespace only (spaces, tab, newline) asks for a name, not for a letter", () => {
|
|
75
|
+
// Rule order: trimming to empty is caught BEFORE the slug rule.
|
|
76
|
+
for (const input of [" ", " ", "\t", "\n", " \t \n "]) {
|
|
77
|
+
expect(profileIdForName(input, MANIFEST)).toEqual({
|
|
78
|
+
ok: false,
|
|
79
|
+
reason: "Enter a name for the profile.",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("C-1: empty input is rejected even against an empty manifest", () => {
|
|
85
|
+
expect(profileIdForName("", EMPTY)).toEqual({
|
|
86
|
+
ok: false,
|
|
87
|
+
reason: "Enter a name for the profile.",
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("profileIdForName — no usable characters (C-3)", () => {
|
|
93
|
+
test("C-3: symbols only is rejected with the letter-or-digit reason", () => {
|
|
94
|
+
for (const input of ["!!!", "---", "@#$%", " _-_ ", "...", "()[]{}"]) {
|
|
95
|
+
expect(profileIdForName(input, MANIFEST)).toEqual({
|
|
96
|
+
ok: false,
|
|
97
|
+
reason: "Use at least one letter or digit.",
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("C-3: letters outside [a-z0-9] leave no id and are rejected", () => {
|
|
103
|
+
// Non-ASCII letters are outside [a-z0-9] after lowercasing.
|
|
104
|
+
expect(profileIdForName("ÄÖÜ", EMPTY)).toEqual({
|
|
105
|
+
ok: false,
|
|
106
|
+
reason: "Use at least one letter or digit.",
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("profileIdForName — id derivation (C-2, C-5)", () => {
|
|
112
|
+
const cases: Array<[string, string, string]> = [
|
|
113
|
+
// [input, expected id, expected name]
|
|
114
|
+
["backend", "backend", "backend"],
|
|
115
|
+
["Backend", "backend", "Backend"],
|
|
116
|
+
["My Team", "my-team", "My Team"],
|
|
117
|
+
["MiXeD CaSe", "mixed-case", "MiXeD CaSe"],
|
|
118
|
+
["UPPER123", "upper123", "UPPER123"],
|
|
119
|
+
["v2", "v2", "v2"],
|
|
120
|
+
["2026", "2026", "2026"],
|
|
121
|
+
["a.b.c", "a-b-c", "a.b.c"],
|
|
122
|
+
["Front--End!!v2", "front-end-v2", "Front--End!!v2"],
|
|
123
|
+
["one two", "one-two", "one two"],
|
|
124
|
+
["snake_case_name", "snake-case-name", "snake_case_name"],
|
|
125
|
+
["--Hello__World--", "hello-world", "--Hello__World--"],
|
|
126
|
+
["!!Release!!", "release", "!!Release!!"],
|
|
127
|
+
["Café Team", "caf-team", "Café Team"],
|
|
128
|
+
["x", "x", "x"],
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
for (const [input, id, name] of cases) {
|
|
132
|
+
test(`C-2/C-5: "${input}" becomes id "${id}"`, () => {
|
|
133
|
+
expect(profileIdForName(input, MANIFEST)).toEqual({ ok: true, id, name });
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
test("C-5: the returned name is trimmed at both ends, inner spacing kept", () => {
|
|
138
|
+
expect(profileIdForName(" My Team \t", MANIFEST)).toEqual({
|
|
139
|
+
ok: true,
|
|
140
|
+
id: "my-team",
|
|
141
|
+
name: "My Team",
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("C-2: the id never starts or ends with a dash", () => {
|
|
146
|
+
const result = profileIdForName(" -*- Night Build -*- ", EMPTY);
|
|
147
|
+
expect(result).toEqual({
|
|
148
|
+
ok: true,
|
|
149
|
+
id: "night-build",
|
|
150
|
+
name: "-*- Night Build -*-",
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("C-2: a derived id contains only [a-z0-9] and single dashes", () => {
|
|
155
|
+
const result = profileIdForName("Q3 / Mobile & Web — Beta!!", EMPTY);
|
|
156
|
+
expect(result.ok).toBe(true);
|
|
157
|
+
if (!result.ok) return;
|
|
158
|
+
expect(result.id).toBe("q3-mobile-web-beta");
|
|
159
|
+
expect(result.id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("profileIdForName — collisions (C-4, REQ-3)", () => {
|
|
164
|
+
test("C-4: a name deriving an existing id is refused, naming that profile's display name", () => {
|
|
165
|
+
expect(profileIdForName("lean", MANIFEST)).toEqual({
|
|
166
|
+
ok: false,
|
|
167
|
+
reason: 'A profile called "Lean" already exists. Choose another name.',
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("C-4: the collision is judged on the derived id, whatever the case or padding", () => {
|
|
172
|
+
for (const input of ["LEAN", " Lean ", "lean!", "--lean--"]) {
|
|
173
|
+
expect(profileIdForName(input, MANIFEST)).toEqual({
|
|
174
|
+
ok: false,
|
|
175
|
+
reason: 'A profile called "Lean" already exists. Choose another name.',
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("C-4: the reason quotes the EXISTING profile's name, not what the user typed", () => {
|
|
181
|
+
expect(profileIdForName("FULL", MANIFEST)).toEqual({
|
|
182
|
+
ok: false,
|
|
183
|
+
reason:
|
|
184
|
+
'A profile called "Full Stack" already exists. Choose another name.',
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("C-4: a profile with no name is quoted by its id", () => {
|
|
189
|
+
expect(profileIdForName("Default", NAMELESS)).toEqual({
|
|
190
|
+
ok: false,
|
|
191
|
+
reason: 'A profile called "default" already exists. Choose another name.',
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("REQ-3: a colliding name is never silently moved to another id", () => {
|
|
196
|
+
// The old flow proposed `default-2`; a typed name that collides must be
|
|
197
|
+
// refused, not renumbered.
|
|
198
|
+
const result = profileIdForName("default", NAMELESS);
|
|
199
|
+
expect(result.ok).toBe(false);
|
|
200
|
+
expect(result).not.toHaveProperty("id");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("C-4: matching an existing DISPLAY name under a different id is not a collision", () => {
|
|
204
|
+
// "Full Stack" is the name of id `full`; its derived id is `full-stack`,
|
|
205
|
+
// which is not a key — the rule compares ids only.
|
|
206
|
+
expect(profileIdForName("Full Stack", MANIFEST)).toEqual({
|
|
207
|
+
ok: true,
|
|
208
|
+
id: "full-stack",
|
|
209
|
+
name: "Full Stack",
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("C-4: a name whose id merely starts with an existing id is fine", () => {
|
|
214
|
+
expect(profileIdForName("Lean 2", MANIFEST)).toEqual({
|
|
215
|
+
ok: true,
|
|
216
|
+
id: "lean-2",
|
|
217
|
+
name: "Lean 2",
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("profileIdForName — purity (REQ-3)", () => {
|
|
223
|
+
test("REQ-3: it does not modify the manifest and answers the same twice", () => {
|
|
224
|
+
const before = structuredClone(MANIFEST);
|
|
225
|
+
const first = profileIdForName("New One", MANIFEST);
|
|
226
|
+
const second = profileIdForName("New One", MANIFEST);
|
|
227
|
+
expect(first).toEqual(second);
|
|
228
|
+
expect(MANIFEST).toEqual(before);
|
|
229
|
+
// A valid name is not registered anywhere by being checked.
|
|
230
|
+
expect(Object.keys(MANIFEST.profiles)).toEqual(["lean", "full"]);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe("the save row (REQ-1, REQ-6)", () => {
|
|
235
|
+
const unmigrated = {
|
|
236
|
+
state: "unmigrated" as const,
|
|
237
|
+
adopt: "default",
|
|
238
|
+
activate: [],
|
|
239
|
+
manifest: EMPTY,
|
|
240
|
+
};
|
|
241
|
+
const inactive = {
|
|
242
|
+
state: "inactive" as const,
|
|
243
|
+
adopt: "default-2",
|
|
244
|
+
activate: [
|
|
245
|
+
{ id: "lean", name: "Lean", pluginCount: 1 },
|
|
246
|
+
{ id: "full", name: "Full Stack", pluginCount: 2 },
|
|
247
|
+
],
|
|
248
|
+
manifest: MANIFEST,
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
for (const need of [unmigrated, inactive]) {
|
|
252
|
+
test(`REQ-1/REQ-6: in the ${need.state} state the save row reads exactly "${SAVE_LABEL}"`, () => {
|
|
253
|
+
const save = buildProfileOptions(need).find(
|
|
254
|
+
(o) => o.value === ADOPT_VALUE,
|
|
255
|
+
);
|
|
256
|
+
expect(save).toBeDefined();
|
|
257
|
+
expect(save?.label).toBe(SAVE_LABEL);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test(`REQ-1: in the ${need.state} state the save row does not show the generated id`, () => {
|
|
261
|
+
const save = buildProfileOptions(need).find(
|
|
262
|
+
(o) => o.value === ADOPT_VALUE,
|
|
263
|
+
);
|
|
264
|
+
expect(save?.label).not.toContain(need.adopt);
|
|
265
|
+
expect(save?.label).not.toContain('"');
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe("applyProfileChoice writes the user's name (REQ-4, REQ-6, C-6)", () => {
|
|
271
|
+
let project: string;
|
|
272
|
+
let wasTTY: boolean | undefined;
|
|
273
|
+
let log: ReturnType<typeof spyOn>;
|
|
274
|
+
let err: ReturnType<typeof spyOn>;
|
|
275
|
+
|
|
276
|
+
const enable = (ids: string[]) =>
|
|
277
|
+
fs.outputJson(join(project, ".claude", "settings.json"), {
|
|
278
|
+
enabledPlugins: Object.fromEntries(ids.map((id) => [id, true])),
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
beforeEach(async () => {
|
|
282
|
+
project = await mkdtemp(join(tmpdir(), "name-on-save-"));
|
|
283
|
+
wasTTY = process.stdin.isTTY;
|
|
284
|
+
Object.defineProperty(process.stdin, "isTTY", {
|
|
285
|
+
value: false,
|
|
286
|
+
configurable: true,
|
|
287
|
+
});
|
|
288
|
+
log = spyOn(console, "log").mockImplementation(() => {});
|
|
289
|
+
err = spyOn(console, "error").mockImplementation(() => {});
|
|
290
|
+
});
|
|
291
|
+
afterEach(async () => {
|
|
292
|
+
log.mockRestore();
|
|
293
|
+
err.mockRestore();
|
|
294
|
+
Object.defineProperty(process.stdin, "isTTY", {
|
|
295
|
+
value: wasTTY,
|
|
296
|
+
configurable: true,
|
|
297
|
+
});
|
|
298
|
+
await rm(project, { recursive: true, force: true });
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("REQ-4: adopt with a name writes that name under the given id", async () => {
|
|
302
|
+
await writeManifest(MANIFEST, project);
|
|
303
|
+
await enable(["code-search@magus"]);
|
|
304
|
+
|
|
305
|
+
const choice: ProfileChoice = {
|
|
306
|
+
kind: "adopt",
|
|
307
|
+
id: "my-team",
|
|
308
|
+
name: "My Team",
|
|
309
|
+
};
|
|
310
|
+
const result = await applyProfileChoice(choice, MANIFEST, project, {
|
|
311
|
+
onDisplace: () => {},
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
expect(result.profileId).toBe("my-team");
|
|
315
|
+
const written = await readManifest(project);
|
|
316
|
+
expect(written.profiles["my-team"]).toBeDefined();
|
|
317
|
+
expect(written.profiles["my-team"]?.name).toBe("My Team");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("REQ-4: adopting under a new name leaves every existing profile untouched", async () => {
|
|
321
|
+
await writeManifest(MANIFEST, project);
|
|
322
|
+
await enable(["code-search@magus"]);
|
|
323
|
+
|
|
324
|
+
await applyProfileChoice(
|
|
325
|
+
{ kind: "adopt", id: "my-team", name: "My Team" },
|
|
326
|
+
MANIFEST,
|
|
327
|
+
project,
|
|
328
|
+
{ onDisplace: () => {} },
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
const written = await readManifest(project);
|
|
332
|
+
expect(written.profiles.lean).toEqual(MANIFEST.profiles.lean);
|
|
333
|
+
expect(written.profiles.full).toEqual(MANIFEST.profiles.full);
|
|
334
|
+
expect(Object.keys(written.profiles).sort()).toEqual([
|
|
335
|
+
"full",
|
|
336
|
+
"lean",
|
|
337
|
+
"my-team",
|
|
338
|
+
]);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("REQ-4/REQ-6: inactive state — detect, choose save, name it, apply", async () => {
|
|
342
|
+
await writeManifest(MANIFEST, project);
|
|
343
|
+
await enable(["code-search@magus"]);
|
|
344
|
+
|
|
345
|
+
const need = await detectProfileStartupNeed(project);
|
|
346
|
+
expect(need.state).toBe("inactive");
|
|
347
|
+
const picked = parseProfileChoice(ADOPT_VALUE, need);
|
|
348
|
+
expect(picked).toEqual({ kind: "adopt", id: need.adopt as string });
|
|
349
|
+
|
|
350
|
+
const check = profileIdForName(" Release Candidate ", need.manifest);
|
|
351
|
+
expect(check).toEqual({
|
|
352
|
+
ok: true,
|
|
353
|
+
id: "release-candidate",
|
|
354
|
+
name: "Release Candidate",
|
|
355
|
+
});
|
|
356
|
+
if (!check.ok || !picked) throw new Error("unreachable");
|
|
357
|
+
|
|
358
|
+
const result = await applyProfileChoice(
|
|
359
|
+
{ ...picked, id: check.id, name: check.name } as ProfileChoice,
|
|
360
|
+
need.manifest,
|
|
361
|
+
project,
|
|
362
|
+
{ onDisplace: () => {} },
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
expect(result.profileId).toBe("release-candidate");
|
|
366
|
+
const written = await readManifest(project);
|
|
367
|
+
expect(written.profiles["release-candidate"]?.name).toBe(
|
|
368
|
+
"Release Candidate",
|
|
369
|
+
);
|
|
370
|
+
// No auto-numbered profile was created alongside it.
|
|
371
|
+
expect(written.profiles[need.adopt as string]).toBeUndefined();
|
|
372
|
+
expect(written.profiles.lean).toEqual(MANIFEST.profiles.lean);
|
|
373
|
+
expect(written.profiles.full).toEqual(MANIFEST.profiles.full);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("REQ-4/REQ-6: unmigrated state — the first profile gets the typed name", async () => {
|
|
377
|
+
// No profiles.json at all.
|
|
378
|
+
await enable(["dev@magus"]);
|
|
379
|
+
|
|
380
|
+
const need = await detectProfileStartupNeed(project);
|
|
381
|
+
expect(need.state).toBe("unmigrated");
|
|
382
|
+
|
|
383
|
+
const check = profileIdForName("Personal Setup", need.manifest);
|
|
384
|
+
expect(check).toEqual({
|
|
385
|
+
ok: true,
|
|
386
|
+
id: "personal-setup",
|
|
387
|
+
name: "Personal Setup",
|
|
388
|
+
});
|
|
389
|
+
if (!check.ok) throw new Error("unreachable");
|
|
390
|
+
|
|
391
|
+
const result = await applyProfileChoice(
|
|
392
|
+
{ kind: "adopt", id: check.id, name: check.name },
|
|
393
|
+
need.manifest,
|
|
394
|
+
project,
|
|
395
|
+
{ onDisplace: () => {} },
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
expect(result.profileId).toBe("personal-setup");
|
|
399
|
+
const written = await readManifest(project);
|
|
400
|
+
expect(Object.keys(written.profiles)).toEqual(["personal-setup"]);
|
|
401
|
+
expect(written.profiles["personal-setup"]?.name).toBe("Personal Setup");
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("REQ-4: accepting the suggestion writes the suggested id as the name", async () => {
|
|
405
|
+
await writeManifest(MANIFEST, project);
|
|
406
|
+
await enable(["code-search@magus"]);
|
|
407
|
+
|
|
408
|
+
const need = await detectProfileStartupNeed(project);
|
|
409
|
+
const suggestion = need.adopt as string;
|
|
410
|
+
const check = profileIdForName(suggestion, need.manifest);
|
|
411
|
+
expect(check).toEqual({ ok: true, id: suggestion, name: suggestion });
|
|
412
|
+
if (!check.ok) throw new Error("unreachable");
|
|
413
|
+
|
|
414
|
+
await applyProfileChoice(
|
|
415
|
+
{ kind: "adopt", id: check.id, name: check.name },
|
|
416
|
+
need.manifest,
|
|
417
|
+
project,
|
|
418
|
+
{ onDisplace: () => {} },
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
expect((await readManifest(project)).profiles[suggestion]?.name).toBe(
|
|
422
|
+
suggestion,
|
|
423
|
+
);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("C-6: adopt without a name still creates the profile under its id", async () => {
|
|
427
|
+
await writeManifest(MANIFEST, project);
|
|
428
|
+
await enable(["code-search@magus"]);
|
|
429
|
+
|
|
430
|
+
const result = await applyProfileChoice(
|
|
431
|
+
{ kind: "adopt", id: "default" },
|
|
432
|
+
MANIFEST,
|
|
433
|
+
project,
|
|
434
|
+
{ onDisplace: () => {} },
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
expect(result.profileId).toBe("default");
|
|
438
|
+
const written = await readManifest(project);
|
|
439
|
+
expect(written.profiles.default).toBeDefined();
|
|
440
|
+
expect(written.profiles.lean).toEqual(MANIFEST.profiles.lean);
|
|
441
|
+
expect(written.profiles.full).toEqual(MANIFEST.profiles.full);
|
|
442
|
+
});
|
|
443
|
+
});
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
activationNotes,
|
|
18
18
|
recordRefetchedSkills,
|
|
19
19
|
trackFetchedSkills,
|
|
20
|
-
|
|
20
|
+
trackedGeneratedNotices,
|
|
21
21
|
updatableSkills,
|
|
22
22
|
} from "../services/profile-activate.js";
|
|
23
23
|
import {
|
|
@@ -361,7 +361,7 @@ describe("older layout: committed custom skills the seeding displaced come back"
|
|
|
361
361
|
});
|
|
362
362
|
|
|
363
363
|
expect(prep.lines).toContain(
|
|
364
|
-
'
|
|
364
|
+
'Updated profile "team" to the current profile format',
|
|
365
365
|
);
|
|
366
366
|
expect(
|
|
367
367
|
git(
|
|
@@ -465,7 +465,7 @@ describe("older layout: committed custom skills the seeding displaced come back"
|
|
|
465
465
|
expect(git(project, "init", "-q").status).toBe(0);
|
|
466
466
|
expect(git(project, "add", "-f", ".claude/skills/alpha").status).toBe(0);
|
|
467
467
|
expect(git(project, "commit", "-q", "-m", "oops").status).toBe(0);
|
|
468
|
-
expect(await
|
|
468
|
+
expect(await trackedGeneratedNotices(project)).toEqual([]);
|
|
469
469
|
// A teammate drops alpha from the profile. Were it still magus's, the
|
|
470
470
|
// reconcile would delete the team's committed folder.
|
|
471
471
|
const pulled = manifest();
|