@coopcli/specsketch 2026.902.2 → 2026.904.1
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 +4 -9
- package/dist/cli/index.js +88 -833
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# coopcli specsketch
|
|
2
2
|
|
|
3
|
+
> **There is no login command.** The CLI logins were retired (`retire-cli-login`): the browser flow opens `/cli-auth` on an origin whose Clerk instance moved to specsesh.com, so it cannot complete. Credentials already in `~/.coopcli/config.json` keep working — reading them outlives writing them.
|
|
4
|
+
|
|
5
|
+
|
|
3
6
|
Draw the system. Get the spec.
|
|
4
7
|
|
|
5
8
|
An Excalidraw canvas that turns architecture diagrams into **OpenSpec** change proposals —
|
|
@@ -8,7 +11,6 @@ them, ready for an implementing agent. Local-only: the server binds to 127.0.0.1
|
|
|
8
11
|
generation runs with your own Anthropic credentials.
|
|
9
12
|
|
|
10
13
|
## Quick start
|
|
11
|
-
|
|
12
14
|
```bash
|
|
13
15
|
npx @coopcli/specsketch ./openspec/changes/new-feature-change
|
|
14
16
|
# or: npm install -g @coopcli/specsketch && specsketch ./openspec/changes/new-feature-change
|
|
@@ -16,7 +18,6 @@ npx @coopcli/specsketch ./openspec/changes/new-feature-change
|
|
|
16
18
|
|
|
17
19
|
Open the printed URL, draw labeled boxes and arrows (loose text becomes design notes), and
|
|
18
20
|
press **Generate Spec**. The change directory is the workspace:
|
|
19
|
-
|
|
20
21
|
```
|
|
21
22
|
openspec/changes/new-feature-change/
|
|
22
23
|
├── diagram.excalidraw ← the drawing, autosaved, agent-readable
|
|
@@ -103,10 +104,8 @@ The right-hand panel has a menu with three options:
|
|
|
103
104
|
```
|
|
104
105
|
|
|
105
106
|
## CLI reference
|
|
106
|
-
|
|
107
107
|
```
|
|
108
108
|
specsketch <path-to-openspec-change-dir> [--port <n>] [--share]
|
|
109
|
-
specsketch login [--profile <name>] [--web-url <url>]
|
|
110
109
|
```
|
|
111
110
|
|
|
112
111
|
- The directory is created if it doesn't exist; its basename becomes the change name.
|
|
@@ -121,9 +120,7 @@ specsketch login [--profile <name>] [--web-url <url>]
|
|
|
121
120
|
retention.
|
|
122
121
|
|
|
123
122
|
## Shared sessions (`--share`)
|
|
124
|
-
|
|
125
123
|
```bash
|
|
126
|
-
specsketch login # once per machine
|
|
127
124
|
specsketch ./openspec/changes/new-feature-change --share
|
|
128
125
|
```
|
|
129
126
|
|
|
@@ -135,8 +132,6 @@ live pill with the participant count and a copy-link button.
|
|
|
135
132
|
|
|
136
133
|
Worth knowing:
|
|
137
134
|
|
|
138
|
-
- The host needs a CoopCLI account (`specsketch login` — opens a browser, saves
|
|
139
|
-
credentials to `~/.coopcli/config.json`, the same file `coop login` writes); guests
|
|
140
135
|
never do. The link itself is the capability: anyone holding it can view **and edit**
|
|
141
136
|
until the session ends. The server stores only a hash of the link token.
|
|
142
137
|
- The local server still binds to 127.0.0.1 only — sharing runs over a single outbound
|
|
@@ -158,7 +153,7 @@ Worth knowing:
|
|
|
158
153
|
| "Node NN detected — specsketch needs Node >= 22" | Upgrade Node (https://nodejs.org) |
|
|
159
154
|
| Port already in use | Handled automatically — the server moves to the next free port and prints it; `--port <n>` sets the preferred starting port |
|
|
160
155
|
| Regeneration changed a file you hand-edited | Expected when the drawing implicates it — the diagram is the source of truth; use `git diff`/`git checkout` to recover, and keep change dirs in version control |
|
|
161
|
-
|
|
156
|
+
|
|
162
157
|
| Share link says it no longer works | The host ended the session (Ctrl-C) or it idled out — ask for a fresh link |
|
|
163
158
|
|
|
164
159
|
More: [product page](https://coopcli.com/products/specsketch) ·
|
package/dist/cli/index.js
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
// server/cli.ts
|
|
4
4
|
import { existsSync as existsSync10, readFileSync as readFileSync8, readdirSync as readdirSync8 } from "node:fs";
|
|
5
|
-
import { homedir as
|
|
6
|
-
import { dirname as
|
|
5
|
+
import { homedir as homedir3 } from "node:os";
|
|
6
|
+
import { dirname as dirname5, extname, join as join11, normalize as normalize2, resolve as resolve4 } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
|
-
import { parseArgs
|
|
8
|
+
import { parseArgs } from "node:util";
|
|
9
9
|
import { serve } from "@hono/node-server";
|
|
10
10
|
import Anthropic from "@anthropic-ai/sdk";
|
|
11
11
|
|
|
@@ -1181,730 +1181,6 @@ ${refContext}` : "");
|
|
|
1181
1181
|
return app;
|
|
1182
1182
|
}
|
|
1183
1183
|
|
|
1184
|
-
// ../shared/src/cli-auth.ts
|
|
1185
|
-
import { exec } from "node:child_process";
|
|
1186
|
-
import { randomBytes } from "node:crypto";
|
|
1187
|
-
import { createServer } from "node:http";
|
|
1188
|
-
import { hostname as hostname2 } from "node:os";
|
|
1189
|
-
import { URL as URL2 } from "node:url";
|
|
1190
|
-
|
|
1191
|
-
// ../shared/src/cli-config.ts
|
|
1192
|
-
import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
|
|
1193
|
-
import { homedir as homedir2, hostname } from "node:os";
|
|
1194
|
-
import { join as join3, dirname } from "node:path";
|
|
1195
|
-
|
|
1196
|
-
// ../shared/src/schemas.ts
|
|
1197
|
-
import { z as z3 } from "zod";
|
|
1198
|
-
var planTierSchema = z3.enum(["hobby", "squad", "org"]);
|
|
1199
|
-
var jurisdictionSchema = z3.enum(["eu", "fedramp"]);
|
|
1200
|
-
var locationHintSchema = z3.enum([
|
|
1201
|
-
"wnam",
|
|
1202
|
-
"enam",
|
|
1203
|
-
"weur",
|
|
1204
|
-
"eeur",
|
|
1205
|
-
"apac",
|
|
1206
|
-
"oc",
|
|
1207
|
-
"afr",
|
|
1208
|
-
"me",
|
|
1209
|
-
"sam"
|
|
1210
|
-
]);
|
|
1211
|
-
var sessionNameSchema = z3.string().min(1).max(63).regex(
|
|
1212
|
-
/^[a-z0-9][a-z0-9-]*$/,
|
|
1213
|
-
"Session name must be lowercase alphanumeric with hyphens, starting with a letter or digit"
|
|
1214
|
-
);
|
|
1215
|
-
var teamNameSchema = z3.string().min(1).max(63).regex(
|
|
1216
|
-
/^[a-z0-9][a-z0-9-]*$/,
|
|
1217
|
-
"Team name must be lowercase alphanumeric with hyphens"
|
|
1218
|
-
);
|
|
1219
|
-
var messageKindSchema = z3.enum(["text", "context", "request", "system"]);
|
|
1220
|
-
var messageContentSchema = z3.object({
|
|
1221
|
-
kind: messageKindSchema,
|
|
1222
|
-
// No .max() here: the 10 000-char body limit is business logic owned by the
|
|
1223
|
-
// SessionActor (MAX_BODY_BYTES), which rejects with 413 MESSAGE_TOO_LARGE on
|
|
1224
|
-
// both REST and WS. A schema cap would pre-empt it as a generic 400.
|
|
1225
|
-
body: z3.string().min(1),
|
|
1226
|
-
// Two-arg z.record: identical semantics in zod 3, required form in zod 4
|
|
1227
|
-
// (specsketch bundles this file against zod 4 — keep every construct dual-safe).
|
|
1228
|
-
metadata: z3.record(z3.string(), z3.unknown()).optional()
|
|
1229
|
-
});
|
|
1230
|
-
var createTeamInputSchema = z3.object({
|
|
1231
|
-
name: teamNameSchema
|
|
1232
|
-
});
|
|
1233
|
-
var createSessionInputSchema = z3.object({
|
|
1234
|
-
name: sessionNameSchema,
|
|
1235
|
-
teamId: z3.string().optional(),
|
|
1236
|
-
jurisdiction: jurisdictionSchema.optional(),
|
|
1237
|
-
locationHint: locationHintSchema.optional()
|
|
1238
|
-
});
|
|
1239
|
-
var sendMessageInputSchema = z3.object({
|
|
1240
|
-
content: messageContentSchema
|
|
1241
|
-
});
|
|
1242
|
-
var inviteInputSchema = z3.object({
|
|
1243
|
-
email: z3.string().email(),
|
|
1244
|
-
role: z3.enum(["admin", "member"]).default("member")
|
|
1245
|
-
});
|
|
1246
|
-
var joinTeamInputSchema = z3.object({
|
|
1247
|
-
inviteCode: z3.string().min(1)
|
|
1248
|
-
});
|
|
1249
|
-
var markReadInputSchema = z3.object({
|
|
1250
|
-
notificationIds: z3.array(z3.string().min(1))
|
|
1251
|
-
});
|
|
1252
|
-
var pollQuerySchema = z3.object({
|
|
1253
|
-
since: z3.string().optional(),
|
|
1254
|
-
limit: z3.coerce.number().int().min(1).max(100).default(50),
|
|
1255
|
-
session: z3.string().optional()
|
|
1256
|
-
});
|
|
1257
|
-
var messagesQuerySchema = z3.object({
|
|
1258
|
-
cursor: z3.string().optional(),
|
|
1259
|
-
limit: z3.coerce.number().int().min(1).max(100).default(50)
|
|
1260
|
-
});
|
|
1261
|
-
var teamTypeSchema = z3.enum(["personal", "shared"]);
|
|
1262
|
-
var teamRoleSchema = z3.enum(["owner", "admin", "member"]);
|
|
1263
|
-
var accessPermissionSchema = z3.enum(["read", "write", "admin"]);
|
|
1264
|
-
var accessGrantInputSchema = z3.object({
|
|
1265
|
-
userId: z3.string().min(1),
|
|
1266
|
-
sessionName: sessionNameSchema.nullable().default(null),
|
|
1267
|
-
// null = general
|
|
1268
|
-
permission: accessPermissionSchema
|
|
1269
|
-
});
|
|
1270
|
-
var machineIdSchema = z3.string().min(1).max(63).regex(
|
|
1271
|
-
/^[a-z0-9][a-z0-9-]*$/,
|
|
1272
|
-
"Machine ID must be lowercase alphanumeric with hyphens, starting with a letter or digit"
|
|
1273
|
-
);
|
|
1274
|
-
var clerkProfileInputSchema = z3.object({
|
|
1275
|
-
email: z3.string().email().nullable(),
|
|
1276
|
-
displayName: z3.string().min(1).max(100),
|
|
1277
|
-
avatarUrl: z3.string().url().nullable(),
|
|
1278
|
-
provider: z3.string().min(1).max(50),
|
|
1279
|
-
providerUserId: z3.string().min(1).max(100)
|
|
1280
|
-
});
|
|
1281
|
-
var sessionEnrollmentSchema = z3.object({
|
|
1282
|
-
sessionId: z3.string(),
|
|
1283
|
-
team: z3.string(),
|
|
1284
|
-
teamName: z3.string().optional(),
|
|
1285
|
-
key: z3.string().startsWith("api_"),
|
|
1286
|
-
enrolledAt: z3.string().datetime(),
|
|
1287
|
-
apiUrl: z3.string().url().optional(),
|
|
1288
|
-
machineId: z3.string().optional()
|
|
1289
|
-
});
|
|
1290
|
-
var authConfigSchema = z3.object({
|
|
1291
|
-
key: z3.string().startsWith("api_"),
|
|
1292
|
-
userId: z3.string().min(1),
|
|
1293
|
-
displayName: z3.string().nullable().optional().default(null),
|
|
1294
|
-
email: z3.string().email().nullable(),
|
|
1295
|
-
machineId: z3.string().nullable(),
|
|
1296
|
-
authenticatedAt: z3.string().datetime()
|
|
1297
|
-
});
|
|
1298
|
-
var configBaseFields = {
|
|
1299
|
-
apiUrl: z3.string().url(),
|
|
1300
|
-
machineId: z3.string().nullable(),
|
|
1301
|
-
sessions: z3.record(z3.string(), sessionEnrollmentSchema),
|
|
1302
|
-
defaults: z3.object({
|
|
1303
|
-
team: z3.string().nullable(),
|
|
1304
|
-
session: z3.string().nullable()
|
|
1305
|
-
}),
|
|
1306
|
-
poll: z3.object({
|
|
1307
|
-
lastPollAt: z3.string().datetime().nullable()
|
|
1308
|
-
})
|
|
1309
|
-
};
|
|
1310
|
-
var coopConfigV1Schema = z3.object({
|
|
1311
|
-
version: z3.literal(1),
|
|
1312
|
-
...configBaseFields
|
|
1313
|
-
});
|
|
1314
|
-
var coopConfigV2Schema = z3.object({
|
|
1315
|
-
version: z3.literal(2),
|
|
1316
|
-
...configBaseFields,
|
|
1317
|
-
auth: authConfigSchema.optional()
|
|
1318
|
-
});
|
|
1319
|
-
var profileNameSchema = z3.string().regex(
|
|
1320
|
-
/^[a-z0-9][a-z0-9_-]{0,63}$/,
|
|
1321
|
-
"Profile name must be lowercase alphanumeric with dashes/underscores, max 64 chars"
|
|
1322
|
-
);
|
|
1323
|
-
var coopProfileSchema = z3.object({
|
|
1324
|
-
apiUrl: z3.string().url(),
|
|
1325
|
-
auth: authConfigSchema.optional(),
|
|
1326
|
-
sessions: z3.record(z3.string(), sessionEnrollmentSchema),
|
|
1327
|
-
defaults: z3.object({
|
|
1328
|
-
team: z3.string().nullable(),
|
|
1329
|
-
session: z3.string().nullable()
|
|
1330
|
-
}),
|
|
1331
|
-
poll: z3.object({
|
|
1332
|
-
lastPollAt: z3.string().datetime().nullable()
|
|
1333
|
-
})
|
|
1334
|
-
});
|
|
1335
|
-
var coopConfigV3ObjectSchema = z3.object({
|
|
1336
|
-
version: z3.literal(3),
|
|
1337
|
-
machineId: z3.string().nullable(),
|
|
1338
|
-
defaultProfile: z3.string(),
|
|
1339
|
-
profiles: z3.record(profileNameSchema, coopProfileSchema)
|
|
1340
|
-
});
|
|
1341
|
-
function requireDefaultProfilePointer(config, ctx) {
|
|
1342
|
-
if (!(config.defaultProfile in config.profiles)) {
|
|
1343
|
-
ctx.addIssue({
|
|
1344
|
-
code: "custom",
|
|
1345
|
-
path: ["defaultProfile"],
|
|
1346
|
-
message: `defaultProfile "${config.defaultProfile}" does not name an existing profile`
|
|
1347
|
-
});
|
|
1348
|
-
}
|
|
1349
|
-
}
|
|
1350
|
-
var coopConfigV3Schema = coopConfigV3ObjectSchema.superRefine(
|
|
1351
|
-
requireDefaultProfilePointer
|
|
1352
|
-
);
|
|
1353
|
-
var coopConfigSchema = z3.discriminatedUnion("version", [
|
|
1354
|
-
coopConfigV1Schema,
|
|
1355
|
-
coopConfigV2Schema,
|
|
1356
|
-
coopConfigV3ObjectSchema
|
|
1357
|
-
]).superRefine((config, ctx) => {
|
|
1358
|
-
if (config.version === 3) {
|
|
1359
|
-
requireDefaultProfilePointer(config, ctx);
|
|
1360
|
-
}
|
|
1361
|
-
});
|
|
1362
|
-
var hookStdinSchema = z3.object({
|
|
1363
|
-
session_id: z3.string().optional(),
|
|
1364
|
-
cwd: z3.string().optional(),
|
|
1365
|
-
hook_event_name: z3.string().optional()
|
|
1366
|
-
}).passthrough();
|
|
1367
|
-
var wsClientMessageSchema = z3.discriminatedUnion("type", [
|
|
1368
|
-
z3.object({ type: z3.literal("message"), content: messageContentSchema }),
|
|
1369
|
-
z3.object({ type: z3.literal("ack"), data: z3.object({ messageId: z3.string() }) }),
|
|
1370
|
-
z3.object({ type: z3.literal("presence_request") }),
|
|
1371
|
-
z3.object({ type: z3.literal("ping") })
|
|
1372
|
-
]);
|
|
1373
|
-
var workKindSchema = z3.enum(["implement", "generate"]);
|
|
1374
|
-
var workCompletionStatusSchema = z3.enum(["succeeded", "failed"]);
|
|
1375
|
-
var poolJoinInputSchema = z3.object({}).passthrough();
|
|
1376
|
-
var poolCheckInInputSchema = z3.object({}).passthrough();
|
|
1377
|
-
var poolCompleteInputSchema = z3.object({
|
|
1378
|
-
workId: z3.string().min(1),
|
|
1379
|
-
status: workCompletionStatusSchema,
|
|
1380
|
-
resultRef: z3.string().max(512),
|
|
1381
|
-
assignmentSeq: z3.number().int().nonnegative()
|
|
1382
|
-
});
|
|
1383
|
-
var poolEnqueueInputSchema = z3.object({
|
|
1384
|
-
kind: workKindSchema,
|
|
1385
|
-
source: z3.string().min(1),
|
|
1386
|
-
dedupKey: z3.string().min(1).max(512).optional()
|
|
1387
|
-
});
|
|
1388
|
-
var poolCancelInputSchema = z3.object({
|
|
1389
|
-
workId: z3.string().min(1)
|
|
1390
|
-
});
|
|
1391
|
-
|
|
1392
|
-
// ../shared/src/cli-config.ts
|
|
1393
|
-
var DEFAULT_CONFIG_DIR = join3(homedir2(), ".coopcli");
|
|
1394
|
-
var DEFAULT_CONFIG_PATH = join3(DEFAULT_CONFIG_DIR, "config.json");
|
|
1395
|
-
var PROD_API_URL = "https://api.coopcli.com";
|
|
1396
|
-
function emptyProfile(apiUrl) {
|
|
1397
|
-
return {
|
|
1398
|
-
apiUrl,
|
|
1399
|
-
sessions: {},
|
|
1400
|
-
defaults: { team: null, session: null },
|
|
1401
|
-
poll: { lastPollAt: null }
|
|
1402
|
-
};
|
|
1403
|
-
}
|
|
1404
|
-
var DEFAULT_CONFIG = {
|
|
1405
|
-
version: 3,
|
|
1406
|
-
machineId: hostname(),
|
|
1407
|
-
defaultProfile: "default",
|
|
1408
|
-
profiles: { default: emptyProfile(PROD_API_URL) }
|
|
1409
|
-
};
|
|
1410
|
-
function upgradeToV3(parsed) {
|
|
1411
|
-
if (parsed.version === 3) return parsed;
|
|
1412
|
-
const profile = {
|
|
1413
|
-
apiUrl: parsed.apiUrl,
|
|
1414
|
-
...parsed.version === 2 && parsed.auth ? { auth: parsed.auth } : {},
|
|
1415
|
-
sessions: parsed.sessions,
|
|
1416
|
-
defaults: parsed.defaults,
|
|
1417
|
-
poll: parsed.poll
|
|
1418
|
-
};
|
|
1419
|
-
return {
|
|
1420
|
-
version: 3,
|
|
1421
|
-
machineId: parsed.machineId,
|
|
1422
|
-
defaultProfile: "default",
|
|
1423
|
-
profiles: { default: profile }
|
|
1424
|
-
};
|
|
1425
|
-
}
|
|
1426
|
-
function profileFromEnv(env, envVars) {
|
|
1427
|
-
for (const name of envVars) {
|
|
1428
|
-
const value = env[name];
|
|
1429
|
-
if (value) return value;
|
|
1430
|
-
}
|
|
1431
|
-
return null;
|
|
1432
|
-
}
|
|
1433
|
-
var ConfigStore = class {
|
|
1434
|
-
config = null;
|
|
1435
|
-
selectedProfile = null;
|
|
1436
|
-
configPath;
|
|
1437
|
-
configDir;
|
|
1438
|
-
constructor(configPath) {
|
|
1439
|
-
this.configPath = configPath ?? DEFAULT_CONFIG_PATH;
|
|
1440
|
-
this.configDir = dirname(this.configPath);
|
|
1441
|
-
}
|
|
1442
|
-
/**
|
|
1443
|
-
* Select the profile this store operates on for the rest of the process
|
|
1444
|
-
* (--profile flag / COOP_PROFILE env). Without a selection, the
|
|
1445
|
-
* defaultProfile pointer applies.
|
|
1446
|
-
*/
|
|
1447
|
-
selectProfile(name) {
|
|
1448
|
-
this.selectedProfile = name;
|
|
1449
|
-
}
|
|
1450
|
-
activeProfileName(config) {
|
|
1451
|
-
return this.selectedProfile ?? config.defaultProfile;
|
|
1452
|
-
}
|
|
1453
|
-
view(config) {
|
|
1454
|
-
const profileName = this.activeProfileName(config);
|
|
1455
|
-
const profile = config.profiles[profileName];
|
|
1456
|
-
if (!profile) {
|
|
1457
|
-
const available = Object.keys(config.profiles).join(", ");
|
|
1458
|
-
throw new Error(`Unknown profile "${profileName}". Available profiles: ${available}`);
|
|
1459
|
-
}
|
|
1460
|
-
return { profileName, machineId: config.machineId, ...profile };
|
|
1461
|
-
}
|
|
1462
|
-
/** Returns the active-profile view of the previously loaded config. Throws if load() hasn't been called. */
|
|
1463
|
-
cached() {
|
|
1464
|
-
return this.view(this.cachedRaw());
|
|
1465
|
-
}
|
|
1466
|
-
/** Returns the full previously loaded config. Throws if load() hasn't been called. */
|
|
1467
|
-
cachedRaw() {
|
|
1468
|
-
if (this.config === null) {
|
|
1469
|
-
throw new Error("Config not loaded yet. Call load() first.");
|
|
1470
|
-
}
|
|
1471
|
-
return this.config;
|
|
1472
|
-
}
|
|
1473
|
-
/** Load the config file and return the active profile's view. */
|
|
1474
|
-
async load() {
|
|
1475
|
-
return this.view(await this.loadRaw());
|
|
1476
|
-
}
|
|
1477
|
-
/** Load the config file, normalized to v3. Legacy v1/v2 files upgrade in memory (persisted on next save). */
|
|
1478
|
-
async loadRaw() {
|
|
1479
|
-
if (this.config !== null) {
|
|
1480
|
-
return this.config;
|
|
1481
|
-
}
|
|
1482
|
-
try {
|
|
1483
|
-
const raw = await readFile(this.configPath, "utf8");
|
|
1484
|
-
const parsed = upgradeToV3(coopConfigSchema.parse(JSON.parse(raw)));
|
|
1485
|
-
if (!parsed.machineId) {
|
|
1486
|
-
parsed.machineId = hostname();
|
|
1487
|
-
}
|
|
1488
|
-
this.config = parsed;
|
|
1489
|
-
return parsed;
|
|
1490
|
-
} catch (err) {
|
|
1491
|
-
if (isNodeError(err) && err.code === "ENOENT") {
|
|
1492
|
-
this.config = structuredClone(DEFAULT_CONFIG);
|
|
1493
|
-
return this.config;
|
|
1494
|
-
}
|
|
1495
|
-
if (err instanceof SyntaxError || err?.name === "ZodError") {
|
|
1496
|
-
throw new Error(`Config file (${this.configPath}) is corrupt. Reset with: rm ${this.configPath}`);
|
|
1497
|
-
}
|
|
1498
|
-
throw err;
|
|
1499
|
-
}
|
|
1500
|
-
}
|
|
1501
|
-
async save(config) {
|
|
1502
|
-
await mkdir(this.configDir, { recursive: true, mode: 448 });
|
|
1503
|
-
const content = JSON.stringify(config, null, 2);
|
|
1504
|
-
const tmpPath = this.configPath + ".tmp";
|
|
1505
|
-
await writeFile(tmpPath, content, { mode: 384 });
|
|
1506
|
-
const { rename } = await import("node:fs/promises");
|
|
1507
|
-
await rename(tmpPath, this.configPath);
|
|
1508
|
-
try {
|
|
1509
|
-
await chmod(this.configPath, 384);
|
|
1510
|
-
} catch {
|
|
1511
|
-
}
|
|
1512
|
-
this.config = config;
|
|
1513
|
-
}
|
|
1514
|
-
/** Load, transform the active profile, and save — the single write path for profile-scoped state. */
|
|
1515
|
-
async saveProfile(mutate) {
|
|
1516
|
-
const config = await this.loadRaw();
|
|
1517
|
-
const name = this.activeProfileName(config);
|
|
1518
|
-
const profile = config.profiles[name];
|
|
1519
|
-
if (!profile) {
|
|
1520
|
-
throw new Error(`Profile "${name}" not found in config (${this.configPath})`);
|
|
1521
|
-
}
|
|
1522
|
-
await this.save({
|
|
1523
|
-
...config,
|
|
1524
|
-
profiles: { ...config.profiles, [name]: mutate(profile) }
|
|
1525
|
-
});
|
|
1526
|
-
}
|
|
1527
|
-
/** Validate config for common misconfigurations. Returns warning strings. */
|
|
1528
|
-
validate() {
|
|
1529
|
-
if (!this.config) return [];
|
|
1530
|
-
let view;
|
|
1531
|
-
try {
|
|
1532
|
-
view = this.view(this.config);
|
|
1533
|
-
} catch {
|
|
1534
|
-
return [];
|
|
1535
|
-
}
|
|
1536
|
-
const warnings = [];
|
|
1537
|
-
try {
|
|
1538
|
-
const url = new URL(view.apiUrl);
|
|
1539
|
-
if (!["http:", "https:"].includes(url.protocol)) {
|
|
1540
|
-
warnings.push(`apiUrl protocol "${url.protocol}" is not http/https`);
|
|
1541
|
-
}
|
|
1542
|
-
} catch {
|
|
1543
|
-
warnings.push(`apiUrl "${view.apiUrl}" is not a valid URL`);
|
|
1544
|
-
}
|
|
1545
|
-
if (this.selectedProfile === null && (view.apiUrl.includes("localhost") || view.apiUrl.includes("127.0.0.1"))) {
|
|
1546
|
-
warnings.push(
|
|
1547
|
-
`profile "${view.profileName}" points to localhost \u2014 select it explicitly with --profile, or run coop login`
|
|
1548
|
-
);
|
|
1549
|
-
}
|
|
1550
|
-
return warnings;
|
|
1551
|
-
}
|
|
1552
|
-
async enrollSession(name, enrollment) {
|
|
1553
|
-
await this.saveProfile((profile) => ({
|
|
1554
|
-
...profile,
|
|
1555
|
-
sessions: { ...profile.sessions, [name]: enrollment }
|
|
1556
|
-
}));
|
|
1557
|
-
}
|
|
1558
|
-
async unenrollSession(name) {
|
|
1559
|
-
await this.saveProfile((profile) => {
|
|
1560
|
-
const sessions = { ...profile.sessions };
|
|
1561
|
-
delete sessions[name];
|
|
1562
|
-
const defaults = { ...profile.defaults };
|
|
1563
|
-
if (defaults.session === name) {
|
|
1564
|
-
defaults.session = null;
|
|
1565
|
-
}
|
|
1566
|
-
return { ...profile, sessions, defaults };
|
|
1567
|
-
});
|
|
1568
|
-
}
|
|
1569
|
-
async setDefault(key, value) {
|
|
1570
|
-
await this.saveProfile((profile) => ({
|
|
1571
|
-
...profile,
|
|
1572
|
-
defaults: { ...profile.defaults, [key]: value }
|
|
1573
|
-
}));
|
|
1574
|
-
}
|
|
1575
|
-
async getDefaultSession() {
|
|
1576
|
-
const view = await this.load();
|
|
1577
|
-
return view.defaults.session;
|
|
1578
|
-
}
|
|
1579
|
-
async getSessionKey(sessionName) {
|
|
1580
|
-
const view = await this.load();
|
|
1581
|
-
const enrollment = view.sessions[sessionName];
|
|
1582
|
-
if (!enrollment) {
|
|
1583
|
-
throw new Error(`Not enrolled in session "${sessionName}". Join it first.`);
|
|
1584
|
-
}
|
|
1585
|
-
return enrollment.key;
|
|
1586
|
-
}
|
|
1587
|
-
/** Resolve the API URL for a session, falling back to the active profile's apiUrl. */
|
|
1588
|
-
async getApiUrl(sessionName) {
|
|
1589
|
-
const view = await this.load();
|
|
1590
|
-
if (sessionName) {
|
|
1591
|
-
const enrollment = view.sessions[sessionName];
|
|
1592
|
-
if (enrollment?.apiUrl) return enrollment.apiUrl;
|
|
1593
|
-
}
|
|
1594
|
-
return view.apiUrl;
|
|
1595
|
-
}
|
|
1596
|
-
/** Resolve the machine ID for a session, falling back to the global machineId or the profile's auth. */
|
|
1597
|
-
async getMachineId(sessionName) {
|
|
1598
|
-
const view = await this.load();
|
|
1599
|
-
if (sessionName) {
|
|
1600
|
-
const enrollment = view.sessions[sessionName];
|
|
1601
|
-
if (enrollment?.machineId) return enrollment.machineId;
|
|
1602
|
-
}
|
|
1603
|
-
if (view.machineId) return view.machineId;
|
|
1604
|
-
if (view.auth?.machineId) return view.auth.machineId;
|
|
1605
|
-
return null;
|
|
1606
|
-
}
|
|
1607
|
-
async setMachineId(machineId) {
|
|
1608
|
-
const config = await this.loadRaw();
|
|
1609
|
-
await this.save({ ...config, machineId });
|
|
1610
|
-
}
|
|
1611
|
-
/** Set the active profile's API base URL. */
|
|
1612
|
-
async setApiUrl(apiUrl) {
|
|
1613
|
-
await this.saveProfile((profile) => ({ ...profile, apiUrl }));
|
|
1614
|
-
}
|
|
1615
|
-
/** Create the active profile (empty, at the given endpoint) if it does not exist yet. */
|
|
1616
|
-
async ensureActiveProfile(apiUrl) {
|
|
1617
|
-
const config = await this.loadRaw();
|
|
1618
|
-
const name = this.activeProfileName(config);
|
|
1619
|
-
if (config.profiles[name]) return;
|
|
1620
|
-
await this.save({
|
|
1621
|
-
...config,
|
|
1622
|
-
profiles: { ...config.profiles, [name]: emptyProfile(apiUrl) }
|
|
1623
|
-
});
|
|
1624
|
-
}
|
|
1625
|
-
/** Re-point the defaultProfile fallback. The target must exist. */
|
|
1626
|
-
async setDefaultProfile(name) {
|
|
1627
|
-
const config = await this.loadRaw();
|
|
1628
|
-
if (!config.profiles[name]) {
|
|
1629
|
-
const available = Object.keys(config.profiles).join(", ");
|
|
1630
|
-
throw new Error(`Unknown profile "${name}". Available profiles: ${available}`);
|
|
1631
|
-
}
|
|
1632
|
-
await this.save({ ...config, defaultProfile: name });
|
|
1633
|
-
}
|
|
1634
|
-
/** True if any profile holds an auth block (used to detect the first login). */
|
|
1635
|
-
async hasAnyAuth() {
|
|
1636
|
-
const config = await this.loadRaw();
|
|
1637
|
-
return Object.values(config.profiles).some((profile) => profile.auth !== void 0);
|
|
1638
|
-
}
|
|
1639
|
-
/**
|
|
1640
|
-
* Remove a profile including its credentials and enrollments. The
|
|
1641
|
-
* defaultProfile pointer target cannot be deleted — re-point first.
|
|
1642
|
-
*/
|
|
1643
|
-
async deleteProfile(name) {
|
|
1644
|
-
const config = await this.loadRaw();
|
|
1645
|
-
if (!config.profiles[name]) {
|
|
1646
|
-
const available = Object.keys(config.profiles).join(", ");
|
|
1647
|
-
throw new Error(`Unknown profile "${name}". Available profiles: ${available}`);
|
|
1648
|
-
}
|
|
1649
|
-
if (config.defaultProfile === name) {
|
|
1650
|
-
throw new Error(
|
|
1651
|
-
`Profile "${name}" is the default profile. Re-point it first: coop profile set-default <other>`
|
|
1652
|
-
);
|
|
1653
|
-
}
|
|
1654
|
-
const profiles = { ...config.profiles };
|
|
1655
|
-
delete profiles[name];
|
|
1656
|
-
await this.save({ ...config, profiles });
|
|
1657
|
-
}
|
|
1658
|
-
async updateLastPoll() {
|
|
1659
|
-
await this.saveProfile((profile) => ({
|
|
1660
|
-
...profile,
|
|
1661
|
-
poll: { lastPollAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
1662
|
-
}));
|
|
1663
|
-
}
|
|
1664
|
-
/** Returns true if the active profile has an auth block. */
|
|
1665
|
-
isAuthenticated() {
|
|
1666
|
-
if (this.config === null) return false;
|
|
1667
|
-
return this.view(this.config).auth !== void 0;
|
|
1668
|
-
}
|
|
1669
|
-
/** Returns the user-scoped API key, or throws if not authenticated. */
|
|
1670
|
-
getAuthKey() {
|
|
1671
|
-
const auth = this.config === null ? void 0 : this.view(this.config).auth;
|
|
1672
|
-
if (!auth) {
|
|
1673
|
-
throw new Error("Not authenticated. Run `coop login` first.");
|
|
1674
|
-
}
|
|
1675
|
-
return auth.key;
|
|
1676
|
-
}
|
|
1677
|
-
/** Returns the user ID, or throws if not authenticated. */
|
|
1678
|
-
getUserId() {
|
|
1679
|
-
const auth = this.config === null ? void 0 : this.view(this.config).auth;
|
|
1680
|
-
if (!auth) {
|
|
1681
|
-
throw new Error("Not authenticated. Run `coop login` first.");
|
|
1682
|
-
}
|
|
1683
|
-
return auth.userId;
|
|
1684
|
-
}
|
|
1685
|
-
/**
|
|
1686
|
-
* Saves the auth block into the active profile. Legacy v1/v2 files are
|
|
1687
|
-
* normalized to v3 by load(), so the write always persists v3.
|
|
1688
|
-
*/
|
|
1689
|
-
async setAuth(auth) {
|
|
1690
|
-
await this.saveProfile((profile) => ({ ...profile, auth }));
|
|
1691
|
-
}
|
|
1692
|
-
/**
|
|
1693
|
-
* Returns true if the user should be nudged to run `coop login`:
|
|
1694
|
-
* the active profile has no auth block.
|
|
1695
|
-
*/
|
|
1696
|
-
needsLoginNudge() {
|
|
1697
|
-
if (this.config === null) return true;
|
|
1698
|
-
return this.view(this.config).auth === void 0;
|
|
1699
|
-
}
|
|
1700
|
-
/**
|
|
1701
|
-
* Removes the auth block from the active profile (for `coop logout`).
|
|
1702
|
-
* Sessions are preserved.
|
|
1703
|
-
*/
|
|
1704
|
-
async clearAuth() {
|
|
1705
|
-
await this.saveProfile((profile) => {
|
|
1706
|
-
const { auth: _removed, ...rest } = profile;
|
|
1707
|
-
return rest;
|
|
1708
|
-
});
|
|
1709
|
-
}
|
|
1710
|
-
};
|
|
1711
|
-
function isNodeError(err) {
|
|
1712
|
-
return err instanceof Error && "code" in err;
|
|
1713
|
-
}
|
|
1714
|
-
|
|
1715
|
-
// ../shared/src/cli-auth.ts
|
|
1716
|
-
function resolveDisplayName(name, email, userId) {
|
|
1717
|
-
if (name && !name.startsWith("user_")) return name;
|
|
1718
|
-
if (email) return email.split("@")[0];
|
|
1719
|
-
if (userId) return userId.slice(0, 12);
|
|
1720
|
-
if (name) return name.slice(0, 12);
|
|
1721
|
-
return "unknown";
|
|
1722
|
-
}
|
|
1723
|
-
async function openBrowser(url) {
|
|
1724
|
-
const escaped = url.replace(/"/g, '\\"');
|
|
1725
|
-
const cmd = process.platform === "darwin" ? `open "${escaped}"` : process.platform === "win32" ? `start "" "${escaped}"` : `xdg-open "${escaped}"`;
|
|
1726
|
-
return new Promise((resolve5) => {
|
|
1727
|
-
exec(cmd, () => resolve5());
|
|
1728
|
-
});
|
|
1729
|
-
}
|
|
1730
|
-
var PAGE_STYLE = "body{background:#0f0f0f;color:#e8e8e8;font-family:system-ui;display:flex;justify-content:center;padding-top:4rem}";
|
|
1731
|
-
function htmlPage(title, body) {
|
|
1732
|
-
return `<!DOCTYPE html><html><head><title>${title}</title><style>${PAGE_STYLE}</style></head><body><div><h2>${title}</h2><p>${body}</p></div></body></html>`;
|
|
1733
|
-
}
|
|
1734
|
-
function startLocalhostServer(options) {
|
|
1735
|
-
const timeout = options.timeout ?? 12e4;
|
|
1736
|
-
const loginCommand = options.loginCommand ?? "coop login";
|
|
1737
|
-
return new Promise((resolveServer, rejectServer) => {
|
|
1738
|
-
let resolveCallback;
|
|
1739
|
-
let rejectCallback;
|
|
1740
|
-
const callbackPromise = new Promise((res, rej) => {
|
|
1741
|
-
resolveCallback = res;
|
|
1742
|
-
rejectCallback = rej;
|
|
1743
|
-
});
|
|
1744
|
-
const timer = setTimeout(() => {
|
|
1745
|
-
rejectCallback(new Error(`Login timed out. No browser callback received. Check your browser and try \`${loginCommand}\` again.`));
|
|
1746
|
-
server.close();
|
|
1747
|
-
}, timeout);
|
|
1748
|
-
const server = createServer((req, res) => {
|
|
1749
|
-
const url = new URL2(req.url ?? "/", `http://127.0.0.1`);
|
|
1750
|
-
if (url.pathname === "/") {
|
|
1751
|
-
res.writeHead(200, { "Content-Type": "text/html" });
|
|
1752
|
-
res.end(htmlPage("Waiting for Authentication", `This page was opened by <code>${loginCommand}</code>. Complete sign-in in the browser window.`));
|
|
1753
|
-
return;
|
|
1754
|
-
}
|
|
1755
|
-
if (url.pathname === "/callback") {
|
|
1756
|
-
const state = url.searchParams.get("state") ?? "";
|
|
1757
|
-
if (state !== options.expectedState) {
|
|
1758
|
-
res.writeHead(400, { "Content-Type": "text/html" });
|
|
1759
|
-
res.end(htmlPage("State Mismatch", `Possible CSRF attack. Try <code>${loginCommand}</code> again.`));
|
|
1760
|
-
return;
|
|
1761
|
-
}
|
|
1762
|
-
const key = url.searchParams.get("key") ?? "";
|
|
1763
|
-
if (!key) {
|
|
1764
|
-
res.writeHead(400, { "Content-Type": "text/html" });
|
|
1765
|
-
res.end(htmlPage("Missing Key", `No API key received. Try <code>${loginCommand}</code> again.`));
|
|
1766
|
-
return;
|
|
1767
|
-
}
|
|
1768
|
-
res.writeHead(200, { "Content-Type": "text/html" });
|
|
1769
|
-
res.end(htmlPage("Authentication Complete", "You can close this tab."));
|
|
1770
|
-
clearTimeout(timer);
|
|
1771
|
-
resolveCallback({
|
|
1772
|
-
key,
|
|
1773
|
-
userId: url.searchParams.get("userId") ?? "",
|
|
1774
|
-
email: url.searchParams.get("email") ?? "",
|
|
1775
|
-
displayName: url.searchParams.get("displayName") ?? "",
|
|
1776
|
-
state,
|
|
1777
|
-
apiUrl: url.searchParams.get("apiUrl") ?? ""
|
|
1778
|
-
});
|
|
1779
|
-
return;
|
|
1780
|
-
}
|
|
1781
|
-
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
1782
|
-
res.end("Not Found");
|
|
1783
|
-
});
|
|
1784
|
-
server.listen(0, "127.0.0.1", () => {
|
|
1785
|
-
const addr = server.address();
|
|
1786
|
-
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
1787
|
-
resolveServer({
|
|
1788
|
-
port,
|
|
1789
|
-
close: () => {
|
|
1790
|
-
clearTimeout(timer);
|
|
1791
|
-
server.close();
|
|
1792
|
-
},
|
|
1793
|
-
waitForCallback: () => callbackPromise
|
|
1794
|
-
});
|
|
1795
|
-
});
|
|
1796
|
-
server.on("error", (err) => {
|
|
1797
|
-
clearTimeout(timer);
|
|
1798
|
-
rejectServer(err);
|
|
1799
|
-
});
|
|
1800
|
-
});
|
|
1801
|
-
}
|
|
1802
|
-
function deriveApiUrl(webUrl) {
|
|
1803
|
-
const url = new URL2(webUrl);
|
|
1804
|
-
if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
|
|
1805
|
-
return "http://localhost:8788";
|
|
1806
|
-
}
|
|
1807
|
-
return `https://api.${url.hostname}`;
|
|
1808
|
-
}
|
|
1809
|
-
async function login(input, deps) {
|
|
1810
|
-
const state = randomBytes(16).toString("hex");
|
|
1811
|
-
const machineId = input.machine ?? hostname2();
|
|
1812
|
-
const log = deps.log ?? ((message) => void process.stderr.write(message));
|
|
1813
|
-
const server = await startLocalhostServer({ expectedState: state, loginCommand: input.loginCommand });
|
|
1814
|
-
try {
|
|
1815
|
-
const webUrl = input.webUrl ?? "https://coopcli.com";
|
|
1816
|
-
const authUrl = `${webUrl}/cli-auth?port=${server.port}&state=${state}&machine=${encodeURIComponent(machineId)}`;
|
|
1817
|
-
log(`Opening browser for authentication...
|
|
1818
|
-
`);
|
|
1819
|
-
await deps.openBrowser(authUrl);
|
|
1820
|
-
log(`Waiting for callback on port ${server.port}...
|
|
1821
|
-
`);
|
|
1822
|
-
const params = await server.waitForCallback();
|
|
1823
|
-
const displayName = resolveDisplayName(params.displayName, params.email, params.userId);
|
|
1824
|
-
const resolvedApiUrl = input.apiUrl ?? (params.apiUrl || deriveApiUrl(webUrl));
|
|
1825
|
-
const isFirstLogin = !await deps.config.hasAnyAuth();
|
|
1826
|
-
await deps.config.ensureActiveProfile(resolvedApiUrl);
|
|
1827
|
-
await deps.config.setAuth({
|
|
1828
|
-
key: params.key,
|
|
1829
|
-
userId: params.userId,
|
|
1830
|
-
displayName,
|
|
1831
|
-
email: params.email || null,
|
|
1832
|
-
machineId,
|
|
1833
|
-
authenticatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1834
|
-
});
|
|
1835
|
-
const config = await deps.config.load();
|
|
1836
|
-
if (params.apiUrl && config.apiUrl && params.apiUrl !== config.apiUrl) {
|
|
1837
|
-
log(
|
|
1838
|
-
`
|
|
1839
|
-
Backend mismatch: you authenticated against ${params.apiUrl}
|
|
1840
|
-
but your config points to ${config.apiUrl}
|
|
1841
|
-
Updating config to ${resolvedApiUrl}
|
|
1842
|
-
|
|
1843
|
-
`
|
|
1844
|
-
);
|
|
1845
|
-
}
|
|
1846
|
-
if (resolvedApiUrl !== config.apiUrl) {
|
|
1847
|
-
await deps.config.setApiUrl(resolvedApiUrl);
|
|
1848
|
-
}
|
|
1849
|
-
if (isFirstLogin) {
|
|
1850
|
-
await deps.config.setDefaultProfile(config.profileName);
|
|
1851
|
-
}
|
|
1852
|
-
return {
|
|
1853
|
-
userId: params.userId,
|
|
1854
|
-
email: params.email || null,
|
|
1855
|
-
displayName
|
|
1856
|
-
};
|
|
1857
|
-
} finally {
|
|
1858
|
-
server.close();
|
|
1859
|
-
}
|
|
1860
|
-
}
|
|
1861
|
-
|
|
1862
|
-
// server/login.ts
|
|
1863
|
-
import { parseArgs } from "node:util";
|
|
1864
|
-
var LOGIN_USAGE = `Usage: specsketch login [--profile <name>] [--web-url <url>]
|
|
1865
|
-
|
|
1866
|
-
Opens your browser to sign in to CoopCLI and saves credentials to
|
|
1867
|
-
~/.coopcli/config.json \u2014 the same file and format \`coop login\` uses.
|
|
1868
|
-
|
|
1869
|
-
--profile config profile to log into (default: SPECSKETCH_COOP_PROFILE,
|
|
1870
|
-
then COOP_PROFILE, then the config's default profile). A profile
|
|
1871
|
-
that doesn't exist yet is created.
|
|
1872
|
-
--web-url web app URL for browser auth (default: https://coopcli.com)`;
|
|
1873
|
-
var LoginUsageError = class extends Error {
|
|
1874
|
-
};
|
|
1875
|
-
function parseLoginArgs(argv) {
|
|
1876
|
-
let parsed;
|
|
1877
|
-
try {
|
|
1878
|
-
parsed = parseArgs({
|
|
1879
|
-
args: argv,
|
|
1880
|
-
options: { profile: { type: "string" }, "web-url": { type: "string" } },
|
|
1881
|
-
allowPositionals: true
|
|
1882
|
-
});
|
|
1883
|
-
} catch (err) {
|
|
1884
|
-
throw new LoginUsageError(err instanceof Error ? err.message : String(err));
|
|
1885
|
-
}
|
|
1886
|
-
if (parsed.positionals.length > 0) {
|
|
1887
|
-
throw new LoginUsageError(`unexpected argument: ${parsed.positionals[0]}`);
|
|
1888
|
-
}
|
|
1889
|
-
return {
|
|
1890
|
-
profile: parsed.values.profile ?? null,
|
|
1891
|
-
...parsed.values["web-url"] !== void 0 ? { webUrl: parsed.values["web-url"] } : {}
|
|
1892
|
-
};
|
|
1893
|
-
}
|
|
1894
|
-
async function runLogin(args, deps = {}) {
|
|
1895
|
-
const env = deps.env ?? process.env;
|
|
1896
|
-
const config = deps.config ?? new ConfigStore();
|
|
1897
|
-
const profile = args.profile ?? profileFromEnv(env, ["SPECSKETCH_COOP_PROFILE", "COOP_PROFILE"]);
|
|
1898
|
-
if (profile) config.selectProfile(profile);
|
|
1899
|
-
const result = await login(
|
|
1900
|
-
{ webUrl: args.webUrl, loginCommand: "specsketch login" },
|
|
1901
|
-
// Forward the writer too, so a caller supplying one gets ALL of login's output
|
|
1902
|
-
// rather than just this command's closing line (issue #1).
|
|
1903
|
-
{ config, openBrowser: deps.openBrowser ?? openBrowser, log: deps.log }
|
|
1904
|
-
);
|
|
1905
|
-
(deps.log ?? console.log)(`Authenticated as ${result.email ?? result.userId}. Credentials saved.`);
|
|
1906
|
-
}
|
|
1907
|
-
|
|
1908
1184
|
// server/workspace.ts
|
|
1909
1185
|
import { createHash as createHash2 } from "node:crypto";
|
|
1910
1186
|
import {
|
|
@@ -1915,20 +1191,20 @@ import {
|
|
|
1915
1191
|
renameSync as renameSync2,
|
|
1916
1192
|
writeFileSync as writeFileSync2
|
|
1917
1193
|
} from "node:fs";
|
|
1918
|
-
import { basename as basename2, dirname as
|
|
1194
|
+
import { basename as basename2, dirname as dirname2, join as join4, resolve as resolve2 } from "node:path";
|
|
1919
1195
|
|
|
1920
1196
|
// ../shared/src/openspec-change-meta.ts
|
|
1921
1197
|
import { createHash } from "node:crypto";
|
|
1922
1198
|
import { existsSync as existsSync3, readdirSync as readdirSync3, realpathSync as realpathSync2 } from "node:fs";
|
|
1923
|
-
import { join as
|
|
1199
|
+
import { join as join3 } from "node:path";
|
|
1924
1200
|
import { Document, parseDocument } from "yaml";
|
|
1925
|
-
import { z as
|
|
1201
|
+
import { z as z3 } from "zod";
|
|
1926
1202
|
|
|
1927
1203
|
// ../shared/src/fs-atomic.ts
|
|
1928
1204
|
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, renameSync, writeFileSync } from "node:fs";
|
|
1929
|
-
import { dirname
|
|
1205
|
+
import { dirname } from "node:path";
|
|
1930
1206
|
function writeAtomicFile(target, content) {
|
|
1931
|
-
mkdirSync(
|
|
1207
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
1932
1208
|
const tmp = `${target}.tmp`;
|
|
1933
1209
|
writeFileSync(tmp, content, "utf8");
|
|
1934
1210
|
renameSync(tmp, target);
|
|
@@ -1964,12 +1240,12 @@ var sha256 = (s) => createHash("sha256").update(s).digest("hex");
|
|
|
1964
1240
|
function listBodyFiles(changeDir, fsx = nodeChangeMetaFs) {
|
|
1965
1241
|
const files = [];
|
|
1966
1242
|
for (const name of ["proposal.md", "design.md", "tasks.md"]) {
|
|
1967
|
-
if (fsx.exists(
|
|
1243
|
+
if (fsx.exists(join3(changeDir, name))) files.push(name);
|
|
1968
1244
|
}
|
|
1969
|
-
const specsDir =
|
|
1245
|
+
const specsDir = join3(changeDir, "specs");
|
|
1970
1246
|
for (const entry of fsx.readdir(specsDir).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1971
|
-
const rel =
|
|
1972
|
-
if (entry.directory && fsx.exists(
|
|
1247
|
+
const rel = join3("specs", entry.name, "spec.md");
|
|
1248
|
+
if (entry.directory && fsx.exists(join3(changeDir, rel))) files.push(rel);
|
|
1973
1249
|
}
|
|
1974
1250
|
return files;
|
|
1975
1251
|
}
|
|
@@ -1978,24 +1254,24 @@ function bodyHash(changeDir, fsx = nodeChangeMetaFs) {
|
|
|
1978
1254
|
if (files.length === 0) return null;
|
|
1979
1255
|
const parts = files.map(
|
|
1980
1256
|
(rel) => `${rel}
|
|
1981
|
-
${sha256(fsx.readIfExists(
|
|
1257
|
+
${sha256(fsx.readIfExists(join3(changeDir, rel)) ?? "")}`
|
|
1982
1258
|
);
|
|
1983
1259
|
return `sha256:${sha256(parts.join("\n"))}`;
|
|
1984
1260
|
}
|
|
1985
1261
|
var SPEC_STATUSES = ["draft", "in-progress"];
|
|
1986
|
-
var SpecStatus =
|
|
1262
|
+
var SpecStatus = z3.enum(SPEC_STATUSES);
|
|
1987
1263
|
var LEGACY_STATUS_COERCION = {
|
|
1988
1264
|
active: "in-progress",
|
|
1989
1265
|
done: "in-progress",
|
|
1990
1266
|
deferred: "draft"
|
|
1991
1267
|
};
|
|
1992
|
-
var StoredSpecStatus =
|
|
1268
|
+
var StoredSpecStatus = z3.enum([...SPEC_STATUSES, "active", "done", "deferred"]).transform(
|
|
1993
1269
|
(s) => s in LEGACY_STATUS_COERCION ? LEGACY_STATUS_COERCION[s] : s
|
|
1994
1270
|
);
|
|
1995
|
-
var SpecMetaSchema =
|
|
1996
|
-
specId:
|
|
1997
|
-
userStories:
|
|
1998
|
-
dependencies:
|
|
1271
|
+
var SpecMetaSchema = z3.object({
|
|
1272
|
+
specId: z3.string(),
|
|
1273
|
+
userStories: z3.array(z3.string()),
|
|
1274
|
+
dependencies: z3.array(z3.string()),
|
|
1999
1275
|
/** Working state (retired values coerce on read); absent = draft. */
|
|
2000
1276
|
status: StoredSpecStatus.optional(),
|
|
2001
1277
|
/**
|
|
@@ -2004,18 +1280,18 @@ var SpecMetaSchema = z4.object({
|
|
|
2004
1280
|
* into specs/ and archived (apply-first: the fold happens at approval).
|
|
2005
1281
|
* Absent = not-approved.
|
|
2006
1282
|
*/
|
|
2007
|
-
approved:
|
|
1283
|
+
approved: z3.boolean().optional(),
|
|
2008
1284
|
/**
|
|
2009
1285
|
* Legacy spelling of the fold axis (pre-rename metadata files). Read-only
|
|
2010
1286
|
* compatibility: readers treat `applied: true` as `approved: true`; no
|
|
2011
1287
|
* writer ever sets this field again.
|
|
2012
1288
|
*/
|
|
2013
|
-
applied:
|
|
1289
|
+
applied: z3.boolean().optional(),
|
|
2014
1290
|
/**
|
|
2015
1291
|
* Build flag — user-set, independent of the fold: true once the code has
|
|
2016
1292
|
* been built to match the approved diff. Absent = not-implemented.
|
|
2017
1293
|
*/
|
|
2018
|
-
implemented:
|
|
1294
|
+
implemented: z3.boolean().optional(),
|
|
2019
1295
|
/**
|
|
2020
1296
|
* Minimal implementation-task record written on fold (specplan-timeline-apply
|
|
2021
1297
|
* "Implementation task record on fold"): the creation time and a reference to
|
|
@@ -2024,18 +1300,18 @@ var SpecMetaSchema = z4.object({
|
|
|
2024
1300
|
* state — task execution/dispatch is a separate, not-yet-defined concern. A
|
|
2025
1301
|
* field addition to this sidecar, not a redefinition of its format.
|
|
2026
1302
|
*/
|
|
2027
|
-
task:
|
|
2028
|
-
createdAt:
|
|
2029
|
-
source:
|
|
1303
|
+
task: z3.object({
|
|
1304
|
+
createdAt: z3.string(),
|
|
1305
|
+
source: z3.string()
|
|
2030
1306
|
}).optional(),
|
|
2031
|
-
generation:
|
|
1307
|
+
generation: z3.object({
|
|
2032
1308
|
/** sha256 of the last generated body files — detects hand edits. */
|
|
2033
|
-
lastGeneratedBodyHash:
|
|
2034
|
-
lastGeneratedAt:
|
|
1309
|
+
lastGeneratedBodyHash: z3.string(),
|
|
1310
|
+
lastGeneratedAt: z3.string()
|
|
2035
1311
|
}).optional()
|
|
2036
1312
|
});
|
|
2037
1313
|
function writeChangeMeta(fsx, changeDir, meta) {
|
|
2038
|
-
const path =
|
|
1314
|
+
const path = join3(changeDir, CHANGE_META_FILE);
|
|
2039
1315
|
const raw = fsx.readIfExists(path);
|
|
2040
1316
|
const doc = raw === void 0 ? new Document({
|
|
2041
1317
|
specId: meta.specId,
|
|
@@ -2078,14 +1354,14 @@ var ChangeWorkspace = class {
|
|
|
2078
1354
|
if (options.create !== false) mkdirSync2(this.dir, { recursive: true });
|
|
2079
1355
|
}
|
|
2080
1356
|
writeAtomic(relPath, content) {
|
|
2081
|
-
const target =
|
|
2082
|
-
mkdirSync2(
|
|
1357
|
+
const target = join4(this.dir, relPath);
|
|
1358
|
+
mkdirSync2(dirname2(target), { recursive: true });
|
|
2083
1359
|
const tmp = `${target}.tmp`;
|
|
2084
1360
|
writeFileSync2(tmp, content, "utf8");
|
|
2085
1361
|
renameSync2(tmp, target);
|
|
2086
1362
|
}
|
|
2087
1363
|
readIfExists(relPath) {
|
|
2088
|
-
const target =
|
|
1364
|
+
const target = join4(this.dir, relPath);
|
|
2089
1365
|
return existsSync4(target) ? readFileSync3(target, "utf8") : void 0;
|
|
2090
1366
|
}
|
|
2091
1367
|
// ── Scene ────────────────────────────────────────────────────────────────
|
|
@@ -2147,11 +1423,11 @@ var ChangeWorkspace = class {
|
|
|
2147
1423
|
/** Current artifact files, however they were authored. */
|
|
2148
1424
|
readArtifacts() {
|
|
2149
1425
|
const specs = [];
|
|
2150
|
-
const specsDir =
|
|
1426
|
+
const specsDir = join4(this.dir, "specs");
|
|
2151
1427
|
if (existsSync4(specsDir)) {
|
|
2152
1428
|
for (const entry of readdirSync4(specsDir, { withFileTypes: true })) {
|
|
2153
1429
|
if (!entry.isDirectory()) continue;
|
|
2154
|
-
const spec = this.readIfExists(
|
|
1430
|
+
const spec = this.readIfExists(join4("specs", entry.name, "spec.md"));
|
|
2155
1431
|
if (spec !== void 0) specs.push({ capability: entry.name, spec });
|
|
2156
1432
|
}
|
|
2157
1433
|
}
|
|
@@ -2185,7 +1461,7 @@ var ChangeWorkspace = class {
|
|
|
2185
1461
|
"design.md": bundle.design,
|
|
2186
1462
|
"tasks.md": bundle.tasks
|
|
2187
1463
|
};
|
|
2188
|
-
for (const s of bundle.specs) files[
|
|
1464
|
+
for (const s of bundle.specs) files[join4("specs", s.capability, "spec.md")] = s.spec;
|
|
2189
1465
|
const written = [];
|
|
2190
1466
|
const manifest = {};
|
|
2191
1467
|
for (const [relPath, content] of Object.entries(files)) {
|
|
@@ -2236,11 +1512,11 @@ var ChangeWorkspace = class {
|
|
|
2236
1512
|
|
|
2237
1513
|
// server/workbench-host.ts
|
|
2238
1514
|
import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:fs";
|
|
2239
|
-
import { dirname as
|
|
1515
|
+
import { dirname as dirname4, join as join10, resolve as resolve3 } from "node:path";
|
|
2240
1516
|
|
|
2241
1517
|
// src/workbench/core/capability-store.ts
|
|
2242
1518
|
import { readFileSync as readFileSync4, readdirSync as readdirSync5, statSync as statSync2 } from "node:fs";
|
|
2243
|
-
import { join as
|
|
1519
|
+
import { join as join5 } from "node:path";
|
|
2244
1520
|
|
|
2245
1521
|
// src/workbench/core/spec-parse.ts
|
|
2246
1522
|
var REQUIREMENT_HEADING = /^###[ \t]+Requirement:[ \t]*(.*)$/;
|
|
@@ -2382,8 +1658,8 @@ function readIfPresent(path) {
|
|
|
2382
1658
|
return readFileSync4(path, "utf8");
|
|
2383
1659
|
}
|
|
2384
1660
|
function createFoldedSpecReader(repoRoot) {
|
|
2385
|
-
const specsDir =
|
|
2386
|
-
const specPath = (name) =>
|
|
1661
|
+
const specsDir = join5(repoRoot, "openspec", "specs");
|
|
1662
|
+
const specPath = (name) => join5(specsDir, name, SPEC_FILE);
|
|
2387
1663
|
const readCapability = (name) => {
|
|
2388
1664
|
const invalid = validateCapabilityName(name);
|
|
2389
1665
|
if (invalid) throw new RangeError(invalid);
|
|
@@ -2512,22 +1788,22 @@ function agentRailProfile() {
|
|
|
2512
1788
|
|
|
2513
1789
|
// src/workbench/agent/credential-probe.ts
|
|
2514
1790
|
import { existsSync as existsSync5, readdirSync as readdirSync6, statSync as statSync3 } from "node:fs";
|
|
2515
|
-
import { homedir as
|
|
2516
|
-
import { delimiter, join as
|
|
1791
|
+
import { homedir as homedir2 } from "node:os";
|
|
1792
|
+
import { delimiter, join as join6 } from "node:path";
|
|
2517
1793
|
function claudeOnPath(env) {
|
|
2518
1794
|
const path = env["PATH"];
|
|
2519
1795
|
if (path === void 0 || path === "") return false;
|
|
2520
1796
|
for (const dir of path.split(delimiter)) {
|
|
2521
1797
|
if (dir === "") continue;
|
|
2522
1798
|
try {
|
|
2523
|
-
if (statSync3(
|
|
1799
|
+
if (statSync3(join6(dir, "claude")).isFile()) return true;
|
|
2524
1800
|
} catch {
|
|
2525
1801
|
}
|
|
2526
1802
|
}
|
|
2527
1803
|
return false;
|
|
2528
1804
|
}
|
|
2529
1805
|
function antAuthProfile(home) {
|
|
2530
|
-
const dir =
|
|
1806
|
+
const dir = join6(home, ".config", "anthropic", "credentials");
|
|
2531
1807
|
try {
|
|
2532
1808
|
return readdirSync6(dir).some((f) => f.endsWith(".json")) ? "token" : "empty-profile";
|
|
2533
1809
|
} catch {
|
|
@@ -2535,7 +1811,7 @@ function antAuthProfile(home) {
|
|
|
2535
1811
|
}
|
|
2536
1812
|
}
|
|
2537
1813
|
function subscriptionStatus(home, installed) {
|
|
2538
|
-
if (existsSync5(
|
|
1814
|
+
if (existsSync5(join6(home, ".claude", ".credentials.json"))) return { kind: "available" };
|
|
2539
1815
|
return {
|
|
2540
1816
|
kind: "unavailable",
|
|
2541
1817
|
reason: installed ? "installed-not-authenticated" : "not-installed"
|
|
@@ -2564,7 +1840,7 @@ function antAuthStatus(env, home) {
|
|
|
2564
1840
|
}
|
|
2565
1841
|
function probeMachineCredentials(input = {}) {
|
|
2566
1842
|
const env = input.env ?? process.env;
|
|
2567
|
-
const home = input.home ??
|
|
1843
|
+
const home = input.home ?? homedir2();
|
|
2568
1844
|
const installed = input.claudeInstalled ?? claudeOnPath(env);
|
|
2569
1845
|
const rejected = new Set(input.rejected ?? []);
|
|
2570
1846
|
const statuses = {
|
|
@@ -2664,7 +1940,7 @@ async function runRailTurn(input, deps = {}) {
|
|
|
2664
1940
|
}
|
|
2665
1941
|
|
|
2666
1942
|
// src/workbench/agent/tools.ts
|
|
2667
|
-
import { z as
|
|
1943
|
+
import { z as z4 } from "zod";
|
|
2668
1944
|
var ROOT_ARTIFACTS = ["proposal.md", "design.md", "tasks.md"];
|
|
2669
1945
|
var SPEC_ARTIFACT = /^specs\/([a-z0-9]+(?:-[a-z0-9]+)*)\/spec\.md$/;
|
|
2670
1946
|
var AGENT_PROPOSAL_TOOLS = [
|
|
@@ -2681,22 +1957,22 @@ var AGENT_PROPOSAL_TOOLS = [
|
|
|
2681
1957
|
description: "Propose a requirement, appended to a delta spec. `capability` must be one the change already has a delta spec for. `block` must be full markdown: a `### Requirement:` heading, RFC-2119 SHALL text, and at least one `#### Scenario:` with WHEN/THEN steps."
|
|
2682
1958
|
}
|
|
2683
1959
|
];
|
|
2684
|
-
var proposalCall =
|
|
2685
|
-
tool:
|
|
2686
|
-
artifact:
|
|
2687
|
-
mode:
|
|
2688
|
-
text:
|
|
2689
|
-
title:
|
|
2690
|
-
tasks:
|
|
2691
|
-
capability:
|
|
2692
|
-
requirement:
|
|
2693
|
-
block:
|
|
1960
|
+
var proposalCall = z4.object({
|
|
1961
|
+
tool: z4.enum(AGENT_PROPOSAL_TOOLS.map((t) => t.name)).describe("Which kind of edit to propose."),
|
|
1962
|
+
artifact: z4.string().optional().describe("edit_artifact: the artifact this edit is against."),
|
|
1963
|
+
mode: z4.string().optional().describe("edit_artifact: `replace` or `append`. Defaults to append."),
|
|
1964
|
+
text: z4.string().optional().describe("edit_artifact: the markdown, exactly as it should read."),
|
|
1965
|
+
title: z4.string().optional().describe("add_task_group: the group heading."),
|
|
1966
|
+
tasks: z4.array(z4.string()).optional().describe("add_task_group: task titles, one per task, each one concrete action."),
|
|
1967
|
+
capability: z4.string().optional().describe("add_requirement: the delta spec\u2019s capability. add_task_group: the citation\u2019s."),
|
|
1968
|
+
requirement: z4.string().optional().describe("add_task_group: the exact requirement name, as it appears after `### Requirement:`."),
|
|
1969
|
+
block: z4.string().optional().describe("add_requirement: the complete requirement block, in markdown.")
|
|
2694
1970
|
});
|
|
2695
|
-
var agentTurnResult =
|
|
2696
|
-
answer:
|
|
2697
|
-
proposals:
|
|
1971
|
+
var agentTurnResult = z4.object({
|
|
1972
|
+
answer: z4.string().describe("Brief prose for the author: what you proposed and why, or your answer if nothing."),
|
|
1973
|
+
proposals: z4.array(proposalCall).describe("The edits to propose. Empty when the turn only answers.")
|
|
2698
1974
|
});
|
|
2699
|
-
var AGENT_TURN_SCHEMA =
|
|
1975
|
+
var AGENT_TURN_SCHEMA = z4.toJSONSchema(agentTurnResult, {
|
|
2700
1976
|
target: "draft-7"
|
|
2701
1977
|
});
|
|
2702
1978
|
function knownArtifacts(shape) {
|
|
@@ -2901,7 +2177,7 @@ import {
|
|
|
2901
2177
|
statSync as statSync4,
|
|
2902
2178
|
writeFileSync as writeFileSync3
|
|
2903
2179
|
} from "node:fs";
|
|
2904
|
-
import { dirname as
|
|
2180
|
+
import { dirname as dirname3, join as join7 } from "node:path";
|
|
2905
2181
|
|
|
2906
2182
|
// src/workbench/shell/lifecycle.ts
|
|
2907
2183
|
var SPEC_DRIVEN = Object.freeze({
|
|
@@ -2941,7 +2217,7 @@ function resolveSchema(yamlText, known = [SPEC_DRIVEN]) {
|
|
|
2941
2217
|
|
|
2942
2218
|
// server/change-files.ts
|
|
2943
2219
|
function changeDirectory(repoRoot, changeId) {
|
|
2944
|
-
return
|
|
2220
|
+
return join7(repoRoot, "openspec", "changes", changeId);
|
|
2945
2221
|
}
|
|
2946
2222
|
var ROOT_ARTIFACTS2 = Object.freeze(["proposal.md", "design.md", "tasks.md"]);
|
|
2947
2223
|
var CAPABILITY = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
@@ -2977,7 +2253,7 @@ function readArtifact(changeDir, name, io = REAL_IO) {
|
|
|
2977
2253
|
if (artifact === null) {
|
|
2978
2254
|
return { outcome: "refused", artifact: name, reason: notAnArtifact(name) };
|
|
2979
2255
|
}
|
|
2980
|
-
const path =
|
|
2256
|
+
const path = join7(changeDir, artifact);
|
|
2981
2257
|
let size;
|
|
2982
2258
|
try {
|
|
2983
2259
|
const stat = statSync4(path);
|
|
@@ -3011,17 +2287,17 @@ function readArtifact(changeDir, name, io = REAL_IO) {
|
|
|
3011
2287
|
function listArtifacts(changeDir) {
|
|
3012
2288
|
const found = [];
|
|
3013
2289
|
for (const name of ROOT_ARTIFACTS2) {
|
|
3014
|
-
if (existsSync6(
|
|
2290
|
+
if (existsSync6(join7(changeDir, name))) found.push(name);
|
|
3015
2291
|
}
|
|
3016
2292
|
let capabilities = [];
|
|
3017
2293
|
try {
|
|
3018
|
-
capabilities = readdirSync7(
|
|
2294
|
+
capabilities = readdirSync7(join7(changeDir, "specs"), { withFileTypes: true }).filter((e) => e.isDirectory() && CAPABILITY.test(e.name)).map((e) => e.name).sort();
|
|
3019
2295
|
} catch {
|
|
3020
2296
|
capabilities = [];
|
|
3021
2297
|
}
|
|
3022
2298
|
for (const capability of capabilities) {
|
|
3023
2299
|
const artifact = `specs/${capability}/spec.md`;
|
|
3024
|
-
if (existsSync6(
|
|
2300
|
+
if (existsSync6(join7(changeDir, artifact))) found.push(artifact);
|
|
3025
2301
|
}
|
|
3026
2302
|
return found;
|
|
3027
2303
|
}
|
|
@@ -3030,14 +2306,14 @@ function writeArtifact(changeDir, name, content, options = {}) {
|
|
|
3030
2306
|
if (artifact === null) {
|
|
3031
2307
|
return { outcome: "refused", artifact: name, reason: notAnArtifact(name) };
|
|
3032
2308
|
}
|
|
3033
|
-
const path =
|
|
2309
|
+
const path = join7(changeDir, artifact);
|
|
3034
2310
|
if (options.expected !== void 0 && options.overwrite !== true) {
|
|
3035
2311
|
const conflict = conflictWith(path, artifact, options.expected);
|
|
3036
2312
|
if (conflict) return conflict;
|
|
3037
2313
|
}
|
|
3038
2314
|
const materialised = !existsSync6(changeDir);
|
|
3039
2315
|
try {
|
|
3040
|
-
mkdirSync3(
|
|
2316
|
+
mkdirSync3(dirname3(path), { recursive: true });
|
|
3041
2317
|
writeFileSync3(path, Buffer.from(content, "utf8"));
|
|
3042
2318
|
writeSchemaFile(changeDir);
|
|
3043
2319
|
} catch (err) {
|
|
@@ -3073,7 +2349,7 @@ function conflictWith(path, artifact, expected) {
|
|
|
3073
2349
|
};
|
|
3074
2350
|
}
|
|
3075
2351
|
function writeSchemaFile(changeDir) {
|
|
3076
|
-
const path =
|
|
2352
|
+
const path = join7(changeDir, ".openspec.yaml");
|
|
3077
2353
|
if (existsSync6(path)) return;
|
|
3078
2354
|
writeFileSync3(path, `schema: ${SPEC_DRIVEN.name}
|
|
3079
2355
|
`, "utf8");
|
|
@@ -3099,7 +2375,7 @@ function describeErrno(artifact, err) {
|
|
|
3099
2375
|
// server/openspec-validate.ts
|
|
3100
2376
|
import { spawnSync } from "node:child_process";
|
|
3101
2377
|
import { existsSync as existsSync7 } from "node:fs";
|
|
3102
|
-
import { join as
|
|
2378
|
+
import { join as join8 } from "node:path";
|
|
3103
2379
|
|
|
3104
2380
|
// src/workbench/core/validate.ts
|
|
3105
2381
|
var QUOTED_SUBJECT = /"([^"]+)"/;
|
|
@@ -3136,7 +2412,7 @@ function openSpecFindings(issues, changeId = "") {
|
|
|
3136
2412
|
|
|
3137
2413
|
// server/openspec-validate.ts
|
|
3138
2414
|
var runOpenSpecValidate = (repoRoot, changeId) => {
|
|
3139
|
-
const local =
|
|
2415
|
+
const local = join8(repoRoot, "node_modules", ".bin", "openspec");
|
|
3140
2416
|
const command = existsSync7(local) ? local : "openspec";
|
|
3141
2417
|
const run = spawnSync(command, ["validate", changeId, "--strict", "--json", "--no-interactive"], {
|
|
3142
2418
|
cwd: repoRoot,
|
|
@@ -3189,13 +2465,13 @@ function validateChangeOnDisk(repoRoot, changeId, run = runOpenSpecValidate) {
|
|
|
3189
2465
|
|
|
3190
2466
|
// server/approvals.ts
|
|
3191
2467
|
import { existsSync as existsSync8, readFileSync as readFileSync6, statSync as statSync5, writeFileSync as writeFileSync4 } from "node:fs";
|
|
3192
|
-
import { join as
|
|
2468
|
+
import { join as join9 } from "node:path";
|
|
3193
2469
|
var SIDECAR = ".specsketch.json";
|
|
3194
2470
|
var OVERLAY_DECLARATION = "authoring-overlay \u2014 specsketch only, never lifecycle truth";
|
|
3195
2471
|
var STAGE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
3196
2472
|
var REAL_IO2 = { readText: (path) => readFileSync6(path, "utf8") };
|
|
3197
2473
|
function readApprovals(changeDir, io = REAL_IO2) {
|
|
3198
|
-
const path =
|
|
2474
|
+
const path = join9(changeDir, SIDECAR);
|
|
3199
2475
|
let size;
|
|
3200
2476
|
try {
|
|
3201
2477
|
const stat = statSync5(path);
|
|
@@ -3281,7 +2557,7 @@ function setApproval(changeDir, stage, approved, now = /* @__PURE__ */ new Date(
|
|
|
3281
2557
|
else delete next[stage];
|
|
3282
2558
|
try {
|
|
3283
2559
|
writeFileSync4(
|
|
3284
|
-
|
|
2560
|
+
join9(changeDir, SIDECAR),
|
|
3285
2561
|
`${JSON.stringify({ overlay: OVERLAY_DECLARATION, approvals: next }, null, " ")}
|
|
3286
2562
|
`,
|
|
3287
2563
|
"utf8"
|
|
@@ -3311,7 +2587,7 @@ function changeContextFor(target, source) {
|
|
|
3311
2587
|
let changeText = "";
|
|
3312
2588
|
if (!target.resuming) return { read, changeText };
|
|
3313
2589
|
for (const name of ["proposal.md", "tasks.md"]) {
|
|
3314
|
-
const file =
|
|
2590
|
+
const file = join10(target.changeDir, name);
|
|
3315
2591
|
if (!existsSync9(file)) continue;
|
|
3316
2592
|
changeText += `
|
|
3317
2593
|
|
|
@@ -3320,7 +2596,7 @@ ${readFileSync7(file, "utf8")}`;
|
|
|
3320
2596
|
read.push(name);
|
|
3321
2597
|
}
|
|
3322
2598
|
for (const capability of source.openCapabilities ?? []) {
|
|
3323
|
-
const file =
|
|
2599
|
+
const file = join10(target.changeDir, "specs", capability, "spec.md");
|
|
3324
2600
|
if (!existsSync9(file)) continue;
|
|
3325
2601
|
changeText += `
|
|
3326
2602
|
|
|
@@ -3347,8 +2623,8 @@ var WorkbenchHostError = class extends Error {
|
|
|
3347
2623
|
function findRepoRoot(start) {
|
|
3348
2624
|
let dir = resolve3(start);
|
|
3349
2625
|
for (; ; ) {
|
|
3350
|
-
if (existsSync9(
|
|
3351
|
-
const parent =
|
|
2626
|
+
if (existsSync9(join10(dir, "openspec"))) return dir;
|
|
2627
|
+
const parent = dirname4(dir);
|
|
3352
2628
|
if (parent === dir) return null;
|
|
3353
2629
|
dir = parent;
|
|
3354
2630
|
}
|
|
@@ -3373,7 +2649,7 @@ function resolveTarget(cwd, changeId) {
|
|
|
3373
2649
|
}
|
|
3374
2650
|
function readSchema(target) {
|
|
3375
2651
|
try {
|
|
3376
|
-
return resolveSchema(readFileSync7(
|
|
2652
|
+
return resolveSchema(readFileSync7(join10(target.changeDir, ".openspec.yaml"), "utf8"));
|
|
3377
2653
|
} catch {
|
|
3378
2654
|
return { kind: "unknown-schema", name: "" };
|
|
3379
2655
|
}
|
|
@@ -3500,9 +2776,8 @@ function mountWorkbenchHost(app, options) {
|
|
|
3500
2776
|
}
|
|
3501
2777
|
|
|
3502
2778
|
// server/cli.ts
|
|
3503
|
-
var CLI_VERSION = true ? "2026.09.
|
|
2779
|
+
var CLI_VERSION = true ? "2026.09.04.1" : "dev";
|
|
3504
2780
|
var USAGE = `Usage: specsketch <change-id> [--port <n>] [--fixture]
|
|
3505
|
-
specsketch login [--profile <name>] [--web-url <url>]
|
|
3506
2781
|
specsketch --version
|
|
3507
2782
|
|
|
3508
2783
|
specsketch add-user-search
|
|
@@ -3527,7 +2802,7 @@ var UsageError = class extends Error {
|
|
|
3527
2802
|
function parseCliArgs(argv) {
|
|
3528
2803
|
let parsed;
|
|
3529
2804
|
try {
|
|
3530
|
-
parsed =
|
|
2805
|
+
parsed = parseArgs({
|
|
3531
2806
|
args: argv,
|
|
3532
2807
|
options: { port: { type: "string" }, fixture: { type: "boolean" } },
|
|
3533
2808
|
allowPositionals: true
|
|
@@ -3573,15 +2848,15 @@ var MIME = {
|
|
|
3573
2848
|
".woff": "font/woff"
|
|
3574
2849
|
};
|
|
3575
2850
|
function resolveClientDir(moduleUrl) {
|
|
3576
|
-
const here =
|
|
2851
|
+
const here = dirname5(fileURLToPath(moduleUrl));
|
|
3577
2852
|
const candidates = [
|
|
3578
|
-
|
|
2853
|
+
join11(here, "..", "client"),
|
|
3579
2854
|
// packed: dist/cli -> dist/client
|
|
3580
|
-
|
|
2855
|
+
join11(here, "..", "dist", "client")
|
|
3581
2856
|
// repo: server/ -> dist/client
|
|
3582
2857
|
];
|
|
3583
2858
|
for (const dir of candidates) {
|
|
3584
|
-
if (existsSync10(
|
|
2859
|
+
if (existsSync10(join11(dir, "index.html"))) return dir;
|
|
3585
2860
|
}
|
|
3586
2861
|
return null;
|
|
3587
2862
|
}
|
|
@@ -3589,7 +2864,7 @@ function staticResponse(clientDir, pathname) {
|
|
|
3589
2864
|
const rel = normalize2(decodeURIComponent(pathname)).replace(/^\/+/, "");
|
|
3590
2865
|
const target = resolve4(clientDir, rel === "" ? "index.html" : rel);
|
|
3591
2866
|
if (!target.startsWith(resolve4(clientDir))) return null;
|
|
3592
|
-
const file = existsSync10(target) && extname(target) ? target :
|
|
2867
|
+
const file = existsSync10(target) && extname(target) ? target : join11(clientDir, "index.html");
|
|
3593
2868
|
if (!existsSync10(file)) return null;
|
|
3594
2869
|
return new Response(readFileSync8(file), {
|
|
3595
2870
|
headers: { "Content-Type": MIME[extname(file)] ?? "application/octet-stream" }
|
|
@@ -3603,11 +2878,11 @@ but "Generate Spec" needs one of:
|
|
|
3603
2878
|
\u2022 or the Anthropic CLI's login profile (no key handling needed):
|
|
3604
2879
|
brew install anthropics/tap/ant # macOS
|
|
3605
2880
|
ant auth login`;
|
|
3606
|
-
function detectCredentialSource(env = process.env, home =
|
|
2881
|
+
function detectCredentialSource(env = process.env, home = homedir3()) {
|
|
3607
2882
|
if (env.ANTHROPIC_API_KEY) return "ANTHROPIC_API_KEY";
|
|
3608
2883
|
if (env.ANTHROPIC_AUTH_TOKEN) return "ANTHROPIC_AUTH_TOKEN";
|
|
3609
2884
|
try {
|
|
3610
|
-
const dir =
|
|
2885
|
+
const dir = join11(home, ".config", "anthropic", "credentials");
|
|
3611
2886
|
if (readdirSync8(dir).some((f) => f.endsWith(".json"))) return "anthropic profile";
|
|
3612
2887
|
} catch {
|
|
3613
2888
|
}
|
|
@@ -3627,26 +2902,6 @@ async function main(argv) {
|
|
|
3627
2902
|
return;
|
|
3628
2903
|
}
|
|
3629
2904
|
preflight();
|
|
3630
|
-
if (argv[0] === "login") {
|
|
3631
|
-
let loginArgs;
|
|
3632
|
-
try {
|
|
3633
|
-
loginArgs = parseLoginArgs(argv.slice(1));
|
|
3634
|
-
} catch (err) {
|
|
3635
|
-
console.error(
|
|
3636
|
-
err instanceof LoginUsageError ? `specsketch: ${err.message}
|
|
3637
|
-
|
|
3638
|
-
${LOGIN_USAGE}` : err
|
|
3639
|
-
);
|
|
3640
|
-
process.exit(1);
|
|
3641
|
-
}
|
|
3642
|
-
try {
|
|
3643
|
-
await runLogin(loginArgs);
|
|
3644
|
-
} catch (err) {
|
|
3645
|
-
console.error(`specsketch: ${err instanceof Error ? err.message : String(err)}`);
|
|
3646
|
-
process.exit(1);
|
|
3647
|
-
}
|
|
3648
|
-
return;
|
|
3649
|
-
}
|
|
3650
2905
|
let args;
|
|
3651
2906
|
try {
|
|
3652
2907
|
args = parseCliArgs(argv);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coopcli/specsketch",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.904.1",
|
|
4
4
|
"description": "coopcli specsketch — draw an architecture diagram, generate an OpenSpec change proposal into your openspec/ change directory. Local-only.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|