arcane-cli 1.18.0 → 1.19.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 +3 -2
- package/dist/assets/.arcane/spells/_fragments/needs-you.md +1 -0
- package/dist/assets/.arcane/spells/spell-address-review.md +1 -0
- package/dist/assets/.arcane/spells/spell-adopt-docs.md +1 -0
- package/dist/assets/.arcane/spells/spell-app-store-accounts.md +1 -0
- package/dist/assets/.arcane/spells/spell-architect.md +1 -0
- package/dist/assets/.arcane/spells/spell-close-session.md +1 -0
- package/dist/assets/.arcane/spells/spell-commit-work.md +1 -0
- package/dist/assets/.arcane/spells/spell-create-pull-request.md +1 -0
- package/dist/assets/.arcane/spells/spell-eas-store-deploy.md +1 -0
- package/dist/assets/.arcane/spells/spell-full-cycle.md +1 -0
- package/dist/assets/.arcane/spells/spell-make-discoverable.md +1 -0
- package/dist/assets/.arcane/spells/spell-open-session.md +2 -0
- package/dist/assets/.arcane/spells/spell-parallel-workers.md +1 -0
- package/dist/assets/.arcane/spells/spell-plan.md +1 -0
- package/dist/assets/.arcane/spells/spell-review-batch.md +1 -0
- package/dist/assets/.arcane/spells/spell-review.md +1 -0
- package/dist/assets/.arcane/spells/spell-scope.md +1 -0
- package/dist/assets/.arcane/spells/spell-ship.md +1 -0
- package/dist/assets/.arcane/spells/spell-sync-pull-request.md +1 -0
- package/dist/index.js +2120 -1798
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -7639,7 +7639,7 @@ async function uninstallUserTier(options, storeRoot, manifest) {
|
|
|
7639
7639
|
|
|
7640
7640
|
// src/commands/doctor.ts
|
|
7641
7641
|
import { access as access4, readdir as readdir11, readFile as readFile19, mkdir as mkdir7, copyFile as fsCopyFile } from "fs/promises";
|
|
7642
|
-
import { join as
|
|
7642
|
+
import { join as join24, dirname as dirname12, resolve as resolve7 } from "path";
|
|
7643
7643
|
|
|
7644
7644
|
// src/config/incidents.ts
|
|
7645
7645
|
var INCIDENT_QUEUE = [
|
|
@@ -8086,1266 +8086,1817 @@ async function scanGovernancePlaceholders(governanceDir) {
|
|
|
8086
8086
|
return { status: "scanned", runtimeTokens, activeDocs, templateDocs, findings };
|
|
8087
8087
|
}
|
|
8088
8088
|
|
|
8089
|
-
// src/
|
|
8090
|
-
|
|
8091
|
-
|
|
8092
|
-
|
|
8093
|
-
|
|
8094
|
-
|
|
8095
|
-
|
|
8096
|
-
|
|
8097
|
-
|
|
8098
|
-
|
|
8089
|
+
// src/modules/presence/client.ts
|
|
8090
|
+
import { request } from "http";
|
|
8091
|
+
|
|
8092
|
+
// src/modules/presence/store.ts
|
|
8093
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
8094
|
+
import { chmodSync, existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
8095
|
+
import { join as join23, relative as relative2 } from "path";
|
|
8096
|
+
|
|
8097
|
+
// src/modules/session-store.ts
|
|
8098
|
+
import { execFileSync } from "child_process";
|
|
8099
|
+
import { mkdirSync, readFileSync as readFileSync2, renameSync, rmSync, writeFileSync, existsSync as existsSync2 } from "fs";
|
|
8100
|
+
import { userInfo } from "os";
|
|
8101
|
+
import { dirname as dirname11, join as join22 } from "path";
|
|
8102
|
+
|
|
8103
|
+
// src/modules/credential-store.ts
|
|
8104
|
+
import { createHash as createHash4, randomBytes } from "crypto";
|
|
8105
|
+
var CHUNK_SIZE = 1e3;
|
|
8106
|
+
var MAX_CHUNKS = 64;
|
|
8107
|
+
var INDEX_NAME = "session/index";
|
|
8108
|
+
var CHUNK_PREFIX = "session/";
|
|
8109
|
+
var INDEX_VERSION = 1;
|
|
8110
|
+
var KeychainUnavailableError = class extends Error {
|
|
8111
|
+
constructor(operation, cause) {
|
|
8112
|
+
super(`The OS keychain is not available (${operation} failed).`);
|
|
8113
|
+
this.name = "KeychainUnavailableError";
|
|
8114
|
+
this.cause = cause;
|
|
8099
8115
|
}
|
|
8100
|
-
|
|
8101
|
-
|
|
8102
|
-
|
|
8103
|
-
message: `${raw} \u2014 Node.js 18 or later is required`
|
|
8104
|
-
};
|
|
8116
|
+
};
|
|
8117
|
+
function sha256Hex(value) {
|
|
8118
|
+
return createHash4("sha256").update(value, "utf8").digest("hex");
|
|
8105
8119
|
}
|
|
8106
|
-
|
|
8107
|
-
|
|
8108
|
-
|
|
8109
|
-
|
|
8110
|
-
|
|
8111
|
-
|
|
8112
|
-
|
|
8113
|
-
|
|
8114
|
-
|
|
8115
|
-
|
|
8116
|
-
|
|
8117
|
-
} catch {
|
|
8118
|
-
continue;
|
|
8119
|
-
}
|
|
8120
|
-
installBin ??= flavour.bin;
|
|
8121
|
-
const prefix = id.toLowerCase() + "-";
|
|
8122
|
-
const found = entries.some(
|
|
8123
|
-
(e) => e.toLowerCase() === id.toLowerCase() || e.toLowerCase().startsWith(prefix)
|
|
8124
|
-
);
|
|
8125
|
-
if (found) {
|
|
8126
|
-
return { name, passed: true, message: `${id} is installed` };
|
|
8120
|
+
function chunkName(gen, n) {
|
|
8121
|
+
return `${CHUNK_PREFIX}${gen}/${n}`;
|
|
8122
|
+
}
|
|
8123
|
+
function splitSecret(secret, size = CHUNK_SIZE) {
|
|
8124
|
+
const chunks = [];
|
|
8125
|
+
let start = 0;
|
|
8126
|
+
while (start < secret.length) {
|
|
8127
|
+
let end = Math.min(start + size, secret.length);
|
|
8128
|
+
if (end < secret.length) {
|
|
8129
|
+
const last = secret.charCodeAt(end - 1);
|
|
8130
|
+
if (last >= 55296 && last <= 56319) end -= 1;
|
|
8127
8131
|
}
|
|
8132
|
+
chunks.push(secret.slice(start, end));
|
|
8133
|
+
start = end;
|
|
8128
8134
|
}
|
|
8129
|
-
return
|
|
8130
|
-
name,
|
|
8131
|
-
passed: false,
|
|
8132
|
-
message: `${id} not found. Install via: ${installBin ?? "code"} --install-extension ${id}`,
|
|
8133
|
-
blocking: false
|
|
8134
|
-
};
|
|
8135
|
+
return chunks;
|
|
8135
8136
|
}
|
|
8136
|
-
|
|
8137
|
-
|
|
8138
|
-
const manifestPath2 = join22(targetDir, ".arcane.json");
|
|
8137
|
+
function parseIndex(raw) {
|
|
8138
|
+
let parsed;
|
|
8139
8139
|
try {
|
|
8140
|
-
|
|
8140
|
+
parsed = JSON.parse(raw);
|
|
8141
8141
|
} catch {
|
|
8142
|
-
|
|
8142
|
+
return null;
|
|
8143
|
+
}
|
|
8144
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
8145
|
+
const o = parsed;
|
|
8146
|
+
if (o["v"] !== INDEX_VERSION || typeof o["gen"] !== "string" || !/^[0-9a-f]{16}$/.test(o["gen"]) || typeof o["count"] !== "number" || !Number.isInteger(o["count"]) || o["count"] < 1 || o["count"] > MAX_CHUNKS || typeof o["sha256"] !== "string" || !/^[0-9a-f]{64}$/.test(o["sha256"])) {
|
|
8147
|
+
return null;
|
|
8148
|
+
}
|
|
8149
|
+
return { v: o["v"], gen: o["gen"], count: o["count"], sha256: o["sha256"] };
|
|
8150
|
+
}
|
|
8151
|
+
var ChunkedSecretStore = class {
|
|
8152
|
+
constructor(backend) {
|
|
8153
|
+
this.backend = backend;
|
|
8154
|
+
}
|
|
8155
|
+
backend;
|
|
8156
|
+
guard(operation, fn) {
|
|
8143
8157
|
try {
|
|
8144
|
-
|
|
8145
|
-
|
|
8146
|
-
|
|
8147
|
-
return {
|
|
8148
|
-
name,
|
|
8149
|
-
passed: true,
|
|
8150
|
-
message: "self-hosted source tree \u2014 committed source manifest is authoritative"
|
|
8151
|
-
};
|
|
8152
|
-
}
|
|
8153
|
-
} catch {
|
|
8158
|
+
return fn();
|
|
8159
|
+
} catch (error2) {
|
|
8160
|
+
throw new KeychainUnavailableError(operation, error2);
|
|
8154
8161
|
}
|
|
8155
|
-
return {
|
|
8156
|
-
name,
|
|
8157
|
-
passed: false,
|
|
8158
|
-
message: "No .arcane.json found. Run `spell init` to initialize Arcane in this project.",
|
|
8159
|
-
blocking: false
|
|
8160
|
-
};
|
|
8161
8162
|
}
|
|
8162
|
-
|
|
8163
|
-
|
|
8164
|
-
const
|
|
8165
|
-
if (
|
|
8166
|
-
|
|
8167
|
-
|
|
8168
|
-
|
|
8169
|
-
|
|
8170
|
-
|
|
8163
|
+
/** Reads the stored secret. Never throws for bad data; throws only when the keychain itself fails. */
|
|
8164
|
+
load() {
|
|
8165
|
+
const raw = this.guard("read", () => this.backend.get(INDEX_NAME));
|
|
8166
|
+
if (raw === null) return { state: "signed-out" };
|
|
8167
|
+
const index = parseIndex(raw);
|
|
8168
|
+
if (index === null) return { state: "corrupt", reason: "the index entry is not valid" };
|
|
8169
|
+
const parts = [];
|
|
8170
|
+
for (let n = 0; n < index.count; n += 1) {
|
|
8171
|
+
const part = this.guard("read", () => this.backend.get(chunkName(index.gen, n)));
|
|
8172
|
+
if (part === null) return { state: "corrupt", reason: `chunk ${n} of ${index.count} is missing` };
|
|
8173
|
+
parts.push(part);
|
|
8171
8174
|
}
|
|
8172
|
-
|
|
8173
|
-
|
|
8174
|
-
|
|
8175
|
-
|
|
8176
|
-
};
|
|
8177
|
-
} catch {
|
|
8178
|
-
return {
|
|
8179
|
-
name,
|
|
8180
|
-
passed: false,
|
|
8181
|
-
message: ".arcane.json is present but contains invalid JSON. Re-run `spell init`."
|
|
8182
|
-
};
|
|
8175
|
+
const secret = parts.join("");
|
|
8176
|
+
if (sha256Hex(secret) !== index.sha256) {
|
|
8177
|
+
return { state: "corrupt", reason: "the stored pieces do not match their checksum" };
|
|
8178
|
+
}
|
|
8179
|
+
return { state: "ok", secret };
|
|
8183
8180
|
}
|
|
8184
|
-
|
|
8185
|
-
|
|
8186
|
-
|
|
8187
|
-
|
|
8188
|
-
|
|
8189
|
-
|
|
8190
|
-
|
|
8191
|
-
|
|
8181
|
+
/**
|
|
8182
|
+
* Replaces the stored secret. The previous session stays readable until the
|
|
8183
|
+
* new index is written; a failure before that leaves it untouched.
|
|
8184
|
+
*/
|
|
8185
|
+
save(secret) {
|
|
8186
|
+
if (secret.length === 0) throw new Error("Refusing to store an empty secret.");
|
|
8187
|
+
const gen = randomBytes(8).toString("hex");
|
|
8188
|
+
const chunks = splitSecret(secret);
|
|
8189
|
+
if (chunks.length > MAX_CHUNKS) {
|
|
8190
|
+
throw new Error(`Refusing to store a secret longer than ${MAX_CHUNKS * CHUNK_SIZE} characters.`);
|
|
8191
|
+
}
|
|
8192
|
+
chunks.forEach((chunk, n) => {
|
|
8193
|
+
this.guard("write", () => this.backend.set(chunkName(gen, n), chunk));
|
|
8194
|
+
});
|
|
8195
|
+
const index = { v: INDEX_VERSION, gen, count: chunks.length, sha256: sha256Hex(secret) };
|
|
8196
|
+
this.guard("write", () => this.backend.set(INDEX_NAME, JSON.stringify(index)));
|
|
8197
|
+
try {
|
|
8198
|
+
this.sweep(gen);
|
|
8199
|
+
} catch {
|
|
8192
8200
|
}
|
|
8193
|
-
return {
|
|
8194
|
-
name,
|
|
8195
|
-
passed: false,
|
|
8196
|
-
message: `pull.rebase resolves to "${value}", not "true" -- a bare \`git pull\` will create merge commits, contradicting git-conventions.md's rebase-and-fast-forward mandate. Run: git config --local pull.rebase true`,
|
|
8197
|
-
blocking: false
|
|
8198
|
-
};
|
|
8199
|
-
} catch {
|
|
8200
|
-
return {
|
|
8201
|
-
name,
|
|
8202
|
-
passed: false,
|
|
8203
|
-
message: "pull.rebase is unset, not a recognized boolean, or this isn't a Git repository -- effective behavior may not be rebase-and-fast-forward, and Git for Windows defaults this to false when unset. Run: git config --local pull.rebase true",
|
|
8204
|
-
blocking: false
|
|
8205
|
-
};
|
|
8206
8201
|
}
|
|
8207
|
-
|
|
8208
|
-
|
|
8209
|
-
|
|
8210
|
-
|
|
8211
|
-
return {
|
|
8212
|
-
name: "Incident release gate (ARC-024)",
|
|
8213
|
-
passed: false,
|
|
8214
|
-
message: result.blockers.join("; ")
|
|
8215
|
-
};
|
|
8202
|
+
/** Deletes everything this store wrote, strays included. Succeeds when nothing is stored. */
|
|
8203
|
+
clear() {
|
|
8204
|
+
this.guard("delete", () => this.backend.delete(INDEX_NAME));
|
|
8205
|
+
this.sweep(null);
|
|
8216
8206
|
}
|
|
8217
|
-
|
|
8207
|
+
/** Removes every chunk that does not belong to `keepGen` (all chunks when it is null). */
|
|
8208
|
+
sweep(keepGen) {
|
|
8209
|
+
const names = this.guard("list", () => this.backend.list());
|
|
8210
|
+
for (const name of names) {
|
|
8211
|
+
if (name === INDEX_NAME || !name.startsWith(CHUNK_PREFIX)) continue;
|
|
8212
|
+
if (keepGen !== null && name.startsWith(`${CHUNK_PREFIX}${keepGen}/`)) continue;
|
|
8213
|
+
this.guard("delete", () => this.backend.delete(name));
|
|
8214
|
+
}
|
|
8215
|
+
}
|
|
8216
|
+
};
|
|
8217
|
+
|
|
8218
|
+
// src/modules/keychain-backend.ts
|
|
8219
|
+
import { createRequire as createRequire2 } from "module";
|
|
8220
|
+
var KEYCHAIN_SERVICE = "arcane-cli";
|
|
8221
|
+
function createKeychainBackend(service = KEYCHAIN_SERVICE) {
|
|
8222
|
+
let loaded = null;
|
|
8223
|
+
const keyring = () => {
|
|
8224
|
+
loaded ??= createRequire2(import.meta.url)("@napi-rs/keyring");
|
|
8225
|
+
return loaded;
|
|
8226
|
+
};
|
|
8227
|
+
const entry = (name) => new (keyring()).Entry(service, name);
|
|
8218
8228
|
return {
|
|
8219
|
-
|
|
8220
|
-
|
|
8221
|
-
|
|
8229
|
+
get: (name) => entry(name).getPassword() ?? null,
|
|
8230
|
+
set: (name, value) => {
|
|
8231
|
+
entry(name).setPassword(value);
|
|
8232
|
+
},
|
|
8233
|
+
delete: (name) => {
|
|
8234
|
+
entry(name).deletePassword();
|
|
8235
|
+
},
|
|
8236
|
+
list: () => keyring().findCredentials(service).map((credential) => credential.account)
|
|
8222
8237
|
};
|
|
8223
8238
|
}
|
|
8224
|
-
|
|
8225
|
-
|
|
8226
|
-
|
|
8227
|
-
|
|
8228
|
-
|
|
8229
|
-
|
|
8239
|
+
|
|
8240
|
+
// src/modules/session-store.ts
|
|
8241
|
+
var INSECURE_SESSION_FILE_NAME = "session-insecure.json";
|
|
8242
|
+
var SessionClearError = class extends Error {
|
|
8243
|
+
constructor(keychainError, removed) {
|
|
8244
|
+
super(keychainError.message);
|
|
8245
|
+
this.keychainError = keychainError;
|
|
8246
|
+
this.removed = removed;
|
|
8247
|
+
this.name = "SessionClearError";
|
|
8248
|
+
this.cause = keychainError;
|
|
8230
8249
|
}
|
|
8231
|
-
|
|
8232
|
-
|
|
8233
|
-
|
|
8234
|
-
|
|
8235
|
-
|
|
8236
|
-
};
|
|
8250
|
+
keychainError;
|
|
8251
|
+
removed;
|
|
8252
|
+
};
|
|
8253
|
+
function isIsoDate(value) {
|
|
8254
|
+
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
|
8237
8255
|
}
|
|
8238
|
-
|
|
8239
|
-
|
|
8240
|
-
|
|
8241
|
-
|
|
8242
|
-
|
|
8243
|
-
|
|
8244
|
-
async function checkSessionContinuity(targetDir) {
|
|
8245
|
-
const results = [];
|
|
8246
|
-
for (const file of SESSION_CONTINUITY_FILES) {
|
|
8247
|
-
const name = `Session file: ${file}`;
|
|
8248
|
-
const filePath = join22(targetDir, file);
|
|
8249
|
-
try {
|
|
8250
|
-
await access4(filePath);
|
|
8251
|
-
results.push({ name, passed: true, message: "present" });
|
|
8252
|
-
} catch {
|
|
8253
|
-
results.push({
|
|
8254
|
-
name,
|
|
8255
|
-
passed: false,
|
|
8256
|
-
message: `Missing. Required for spell-close-session. Run \`spell doctor --fix\` to create it.`,
|
|
8257
|
-
blocking: false
|
|
8258
|
-
});
|
|
8259
|
-
}
|
|
8256
|
+
function parseSessionRecord(raw) {
|
|
8257
|
+
let parsed;
|
|
8258
|
+
try {
|
|
8259
|
+
parsed = JSON.parse(raw);
|
|
8260
|
+
} catch {
|
|
8261
|
+
return null;
|
|
8260
8262
|
}
|
|
8261
|
-
return
|
|
8262
|
-
|
|
8263
|
-
|
|
8264
|
-
|
|
8265
|
-
for (const file of SESSION_CONTINUITY_FILES) {
|
|
8266
|
-
const destPath = join22(targetDir, file);
|
|
8267
|
-
try {
|
|
8268
|
-
await access4(destPath);
|
|
8269
|
-
} catch {
|
|
8270
|
-
const escape = findLinkedEscape(targetDir, dirname11(file));
|
|
8271
|
-
if (escape !== null) {
|
|
8272
|
-
console.log(
|
|
8273
|
-
` ! Not created: ${file} \u2014 it is under "${escape.link}", a link to "${escape.resolvesTo}" outside "${resolve7(targetDir)}".`
|
|
8274
|
-
);
|
|
8275
|
-
continue;
|
|
8276
|
-
}
|
|
8277
|
-
const fileEscape = findLinkedFileEscape(targetDir, file);
|
|
8278
|
-
if (fileEscape !== null) {
|
|
8279
|
-
console.log(
|
|
8280
|
-
` ! Not created: ${file} \u2014 it is a link to "${fileEscape.resolvesTo}" outside "${resolve7(targetDir)}".`
|
|
8281
|
-
);
|
|
8282
|
-
continue;
|
|
8283
|
-
}
|
|
8284
|
-
const srcPath = join22(assetsDir, file);
|
|
8285
|
-
await mkdir7(dirname11(destPath), { recursive: true });
|
|
8286
|
-
await fsCopyFile(srcPath, destPath);
|
|
8287
|
-
created.push(file);
|
|
8288
|
-
}
|
|
8263
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
8264
|
+
const o = parsed;
|
|
8265
|
+
if (o["v"] !== 1 || o["environment"] !== "production" && o["environment"] !== "development" || typeof o["sub"] !== "string" || o["sub"] === "" || !(typeof o["email"] === "string" || o["email"] === null) || typeof o["refreshToken"] !== "string" || o["refreshToken"] === "" || !isIsoDate(o["obtainedAt"]) || !isIsoDate(o["expiresAt"])) {
|
|
8266
|
+
return null;
|
|
8289
8267
|
}
|
|
8290
|
-
return
|
|
8268
|
+
return {
|
|
8269
|
+
v: 1,
|
|
8270
|
+
environment: o["environment"],
|
|
8271
|
+
sub: o["sub"],
|
|
8272
|
+
email: o["email"],
|
|
8273
|
+
refreshToken: o["refreshToken"],
|
|
8274
|
+
obtainedAt: o["obtainedAt"],
|
|
8275
|
+
expiresAt: o["expiresAt"]
|
|
8276
|
+
};
|
|
8291
8277
|
}
|
|
8292
|
-
|
|
8293
|
-
|
|
8294
|
-
|
|
8295
|
-
|
|
8296
|
-
try {
|
|
8297
|
-
manifest = await readManifest(storeRoot);
|
|
8298
|
-
} catch (err) {
|
|
8299
|
-
if (err instanceof ManifestNotFoundError) {
|
|
8300
|
-
return {
|
|
8301
|
-
name,
|
|
8302
|
-
passed: true,
|
|
8303
|
-
blocking: false,
|
|
8304
|
-
message: "no user tier installed (optional \u2014 `spell init --user` shares every spell across the repositories on this machine)"
|
|
8305
|
-
};
|
|
8306
|
-
}
|
|
8307
|
-
return {
|
|
8308
|
-
name,
|
|
8309
|
-
passed: false,
|
|
8310
|
-
blocking: false,
|
|
8311
|
-
message: `could not read ${storeRoot}/.arcane.json (${err instanceof Error ? err.message : String(err)}) \u2014 remove it and re-run \`spell init --user\``
|
|
8312
|
-
};
|
|
8278
|
+
var FileSecretBackend = class {
|
|
8279
|
+
constructor(filePath, restrict = restrictToCurrentUser) {
|
|
8280
|
+
this.filePath = filePath;
|
|
8281
|
+
this.restrict = restrict;
|
|
8313
8282
|
}
|
|
8314
|
-
|
|
8315
|
-
|
|
8316
|
-
|
|
8317
|
-
|
|
8318
|
-
|
|
8319
|
-
|
|
8320
|
-
|
|
8321
|
-
|
|
8283
|
+
filePath;
|
|
8284
|
+
restrict;
|
|
8285
|
+
exists() {
|
|
8286
|
+
return existsSync2(this.filePath);
|
|
8287
|
+
}
|
|
8288
|
+
read() {
|
|
8289
|
+
if (!existsSync2(this.filePath)) return {};
|
|
8290
|
+
const parsed = JSON.parse(readFileSync2(this.filePath, "utf8"));
|
|
8291
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
8292
|
+
throw new Error("the session file is not an object");
|
|
8322
8293
|
}
|
|
8323
|
-
const
|
|
8324
|
-
const
|
|
8325
|
-
|
|
8326
|
-
problems.push(`installed at v${manifest.version}, this CLI is v${cliVersion}`);
|
|
8294
|
+
const out = {};
|
|
8295
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
8296
|
+
if (typeof v === "string") out[k] = v;
|
|
8327
8297
|
}
|
|
8328
|
-
|
|
8329
|
-
|
|
8330
|
-
|
|
8298
|
+
return out;
|
|
8299
|
+
}
|
|
8300
|
+
write(entries) {
|
|
8301
|
+
if (Object.keys(entries).length === 0) {
|
|
8302
|
+
rmSync(this.filePath, { force: true });
|
|
8303
|
+
return;
|
|
8331
8304
|
}
|
|
8332
|
-
|
|
8333
|
-
|
|
8334
|
-
|
|
8335
|
-
|
|
8336
|
-
|
|
8337
|
-
|
|
8338
|
-
};
|
|
8305
|
+
mkdirSync(dirname11(this.filePath), { recursive: true });
|
|
8306
|
+
const tmp = `${this.filePath}.${process.pid}.tmp`;
|
|
8307
|
+
writeFileSync(tmp, JSON.stringify(entries), { encoding: "utf8", mode: 384 });
|
|
8308
|
+
try {
|
|
8309
|
+
this.restrict(tmp);
|
|
8310
|
+
} catch (error2) {
|
|
8311
|
+
rmSync(tmp, { force: true });
|
|
8312
|
+
throw new Error(
|
|
8313
|
+
"Could not restrict the session file to your account, so it was not written.",
|
|
8314
|
+
{ cause: error2 }
|
|
8315
|
+
);
|
|
8339
8316
|
}
|
|
8340
|
-
|
|
8341
|
-
return {
|
|
8342
|
-
name,
|
|
8343
|
-
passed: true,
|
|
8344
|
-
blocking: false,
|
|
8345
|
-
message: `v${manifest.version} at ${storeRoot} \u2014 ${health.total} client file(s) in place${customized}`
|
|
8346
|
-
};
|
|
8347
|
-
} catch (err) {
|
|
8348
|
-
return {
|
|
8349
|
-
name,
|
|
8350
|
-
passed: false,
|
|
8351
|
-
blocking: false,
|
|
8352
|
-
message: `could not inspect the user tier at ${storeRoot} (${err instanceof Error ? err.message : String(err)}) \u2014 run \`spell update --user\`; if that fails too, remove the store and re-run \`spell init --user\``
|
|
8353
|
-
};
|
|
8354
|
-
}
|
|
8355
|
-
}
|
|
8356
|
-
async function checkPushPolicy(targetDir) {
|
|
8357
|
-
const name = "Push policy (EF-09)";
|
|
8358
|
-
let manifest;
|
|
8359
|
-
try {
|
|
8360
|
-
manifest = await readManifest(targetDir);
|
|
8361
|
-
} catch {
|
|
8362
|
-
return { name, passed: true, message: "not initialized \u2014 nothing to check" };
|
|
8363
|
-
}
|
|
8364
|
-
const policy = manifest.push_policy ?? "open";
|
|
8365
|
-
if (policy === "open") {
|
|
8366
|
-
return { name, passed: true, message: "open \u2014 pushes allowed" };
|
|
8317
|
+
renameSync(tmp, this.filePath);
|
|
8367
8318
|
}
|
|
8368
|
-
|
|
8369
|
-
|
|
8370
|
-
const where = targets.length === 0 ? "none configured" : targets.map(({ remote, urls }) => `${remote} \u2192 ${urls.length > 0 ? urls.join(", ") : "unknown"}`).join("; ");
|
|
8371
|
-
return {
|
|
8372
|
-
name,
|
|
8373
|
-
passed: false,
|
|
8374
|
-
blocking: false,
|
|
8375
|
-
message: `guarded \u2014 no technical control installed. Push targets: ${where}. Confirm those are the remotes you intend before pushing.`
|
|
8376
|
-
};
|
|
8319
|
+
get(name) {
|
|
8320
|
+
return this.read()[name] ?? null;
|
|
8377
8321
|
}
|
|
8378
|
-
|
|
8379
|
-
|
|
8380
|
-
|
|
8381
|
-
|
|
8382
|
-
if (remotes.length === 0) {
|
|
8383
|
-
return {
|
|
8384
|
-
name,
|
|
8385
|
-
passed: false,
|
|
8386
|
-
blocking: false,
|
|
8387
|
-
message: "blocked \u2014 pre-push hook in place, but no remote is configured, so no push URL is disabled. If a remote is added later, run `spell doctor` again: a --no-verify push to it would succeed until the URL is disabled too."
|
|
8388
|
-
};
|
|
8389
|
-
}
|
|
8390
|
-
return {
|
|
8391
|
-
name,
|
|
8392
|
-
passed: true,
|
|
8393
|
-
message: `blocked \u2014 pre-push hook in place and push URL disabled for ${remotes.join(", ")}`
|
|
8394
|
-
};
|
|
8322
|
+
set(name, value) {
|
|
8323
|
+
const entries = this.read();
|
|
8324
|
+
entries[name] = value;
|
|
8325
|
+
this.write(entries);
|
|
8395
8326
|
}
|
|
8396
|
-
|
|
8397
|
-
|
|
8398
|
-
|
|
8399
|
-
|
|
8400
|
-
|
|
8401
|
-
name,
|
|
8402
|
-
passed: false,
|
|
8403
|
-
blocking: false,
|
|
8404
|
-
// ARC-049 decision 3: name the command that closes the gap.
|
|
8405
|
-
message: `declared "blocked" but not enforced \u2014 ${missing.join("; ")}. The manifest claims a protection this repository does not have. Run \`spell block-push\` to install the controls.`
|
|
8406
|
-
};
|
|
8407
|
-
}
|
|
8408
|
-
async function checkGovernancePlaceholders(targetDir) {
|
|
8409
|
-
const name = "Governance placeholders (ARC-051)";
|
|
8410
|
-
let scan;
|
|
8411
|
-
try {
|
|
8412
|
-
scan = await scanGovernancePlaceholders(join22(targetDir, ".arcane", "governance"));
|
|
8413
|
-
} catch (err) {
|
|
8414
|
-
return {
|
|
8415
|
-
name,
|
|
8416
|
-
passed: false,
|
|
8417
|
-
blocking: false,
|
|
8418
|
-
message: `could not scan .arcane/governance (${err instanceof Error ? err.message : String(err)})`
|
|
8419
|
-
};
|
|
8327
|
+
delete(name) {
|
|
8328
|
+
const entries = this.read();
|
|
8329
|
+
if (!(name in entries)) return;
|
|
8330
|
+
delete entries[name];
|
|
8331
|
+
this.write(entries);
|
|
8420
8332
|
}
|
|
8421
|
-
|
|
8422
|
-
return
|
|
8333
|
+
list() {
|
|
8334
|
+
return Object.keys(this.read());
|
|
8423
8335
|
}
|
|
8424
|
-
|
|
8425
|
-
|
|
8426
|
-
|
|
8427
|
-
passed: true,
|
|
8428
|
-
blocking: false,
|
|
8429
|
-
message: `${scan.activeDocs.length} active doc(s) scanned, no unknown placeholders`
|
|
8430
|
-
};
|
|
8431
|
-
}
|
|
8432
|
-
const listed = scan.findings.map(({ file, tokens }) => `.arcane/governance/${file}: ${tokens.join(", ")}`);
|
|
8433
|
-
return {
|
|
8434
|
-
name,
|
|
8435
|
-
passed: false,
|
|
8436
|
-
blocking: false,
|
|
8437
|
-
message: `unknown placeholder(s) in status: active doc(s) \u2014 ${listed.join("; ")}. Fill each one in; or, if an agent resolves it at use time, add it to the runtime-placeholders list in .arcane/governance/${PLACEHOLDER_STANDARD_FILE}; or, if the document is meant to be filled in by hand, mark it \`status: template\`.`
|
|
8438
|
-
};
|
|
8439
|
-
}
|
|
8440
|
-
var UNOWNED_EXAMPLE_SCOPE = "@example/";
|
|
8441
|
-
var RETIRED_SCAFFOLD_PLACEHOLDER = "<your-mcp-server-package>";
|
|
8442
|
-
async function checkComponentRequires(targetDir) {
|
|
8443
|
-
const name = "Component prerequisites";
|
|
8444
|
-
let manifest;
|
|
8445
|
-
try {
|
|
8446
|
-
manifest = await readManifest(targetDir);
|
|
8447
|
-
} catch {
|
|
8448
|
-
return { name, passed: true, blocking: false, skipped: true, message: "skipped \u2014 no readable .arcane.json" };
|
|
8449
|
-
}
|
|
8450
|
-
if (manifest.scope === "user") {
|
|
8451
|
-
return { name, passed: true, blocking: false, skipped: true, message: "skipped \u2014 the user tier holds spells only" };
|
|
8452
|
-
}
|
|
8453
|
-
if (!Array.isArray(manifest.components)) {
|
|
8454
|
-
return { name, passed: true, blocking: false, skipped: true, message: "skipped \u2014 .arcane.json lists no components" };
|
|
8455
|
-
}
|
|
8456
|
-
const missing = findMissingRequires(manifest.components, manifest.profile);
|
|
8457
|
-
if (missing.length === 0) {
|
|
8458
|
-
return { name, passed: true, blocking: false, message: "every installed component's prerequisites are installed" };
|
|
8336
|
+
/** Removes the file itself, strays and all. */
|
|
8337
|
+
remove() {
|
|
8338
|
+
rmSync(this.filePath, { force: true });
|
|
8459
8339
|
}
|
|
8340
|
+
};
|
|
8341
|
+
function restrictToCurrentUser(filePath) {
|
|
8342
|
+
if (process.platform !== "win32") return;
|
|
8343
|
+
const account = userInfo().username;
|
|
8344
|
+
execFileSync("icacls", [filePath, "/inheritance:r", "/grant:r", `${account}:F`], {
|
|
8345
|
+
stdio: "ignore",
|
|
8346
|
+
windowsHide: true
|
|
8347
|
+
});
|
|
8348
|
+
}
|
|
8349
|
+
function insecureSessionFilePath(homeDir) {
|
|
8350
|
+
return join22(userTierRoot(homeDir), INSECURE_SESSION_FILE_NAME);
|
|
8351
|
+
}
|
|
8352
|
+
function createSessionStorage(options = {}) {
|
|
8353
|
+
const keychain = new ChunkedSecretStore(options.keychainBackend ?? createKeychainBackend());
|
|
8354
|
+
const fileBackend = options.fileBackend ?? new FileSecretBackend(insecureSessionFilePath());
|
|
8355
|
+
const file = new ChunkedSecretStore(fileBackend);
|
|
8356
|
+
const interpret = (result, source) => {
|
|
8357
|
+
if (result.state === "signed-out") return null;
|
|
8358
|
+
if (result.state === "corrupt") return { state: "corrupt", source, reason: result.reason };
|
|
8359
|
+
const record = parseSessionRecord(result.secret);
|
|
8360
|
+
if (record === null) return { state: "corrupt", source, reason: "the stored session is not valid" };
|
|
8361
|
+
return { state: "ok", source, record };
|
|
8362
|
+
};
|
|
8460
8363
|
return {
|
|
8461
|
-
|
|
8462
|
-
|
|
8463
|
-
|
|
8464
|
-
|
|
8364
|
+
load() {
|
|
8365
|
+
let keychainError;
|
|
8366
|
+
try {
|
|
8367
|
+
const fromKeychain = interpret(keychain.load(), "keychain");
|
|
8368
|
+
if (fromKeychain) return fromKeychain;
|
|
8369
|
+
} catch (error2) {
|
|
8370
|
+
if (!(error2 instanceof KeychainUnavailableError)) throw error2;
|
|
8371
|
+
keychainError = error2;
|
|
8372
|
+
}
|
|
8373
|
+
if (fileBackend.exists()) {
|
|
8374
|
+
const fromFile = interpret(file.load(), "file");
|
|
8375
|
+
if (fromFile) return fromFile;
|
|
8376
|
+
}
|
|
8377
|
+
return keychainError ? { state: "signed-out", keychainError } : { state: "signed-out" };
|
|
8378
|
+
},
|
|
8379
|
+
save(record, { insecure }) {
|
|
8380
|
+
const serialized = JSON.stringify(record);
|
|
8381
|
+
if (insecure) {
|
|
8382
|
+
file.save(serialized);
|
|
8383
|
+
try {
|
|
8384
|
+
keychain.clear();
|
|
8385
|
+
} catch {
|
|
8386
|
+
}
|
|
8387
|
+
return;
|
|
8388
|
+
}
|
|
8389
|
+
keychain.save(serialized);
|
|
8390
|
+
fileBackend.remove();
|
|
8391
|
+
},
|
|
8392
|
+
clear() {
|
|
8393
|
+
const removed = [];
|
|
8394
|
+
let keychainError;
|
|
8395
|
+
try {
|
|
8396
|
+
const hadKeychainSession = keychain.load().state !== "signed-out";
|
|
8397
|
+
keychain.clear();
|
|
8398
|
+
if (hadKeychainSession) removed.push("keychain");
|
|
8399
|
+
} catch (error2) {
|
|
8400
|
+
keychainError = error2;
|
|
8401
|
+
}
|
|
8402
|
+
if (fileBackend.exists()) {
|
|
8403
|
+
fileBackend.remove();
|
|
8404
|
+
removed.push("file");
|
|
8405
|
+
}
|
|
8406
|
+
if (keychainError instanceof KeychainUnavailableError) {
|
|
8407
|
+
throw new SessionClearError(keychainError, removed);
|
|
8408
|
+
}
|
|
8409
|
+
if (keychainError) throw keychainError;
|
|
8410
|
+
return { removed };
|
|
8411
|
+
}
|
|
8465
8412
|
};
|
|
8466
8413
|
}
|
|
8467
|
-
|
|
8468
|
-
|
|
8469
|
-
|
|
8470
|
-
|
|
8471
|
-
|
|
8472
|
-
|
|
8473
|
-
|
|
8474
|
-
|
|
8475
|
-
|
|
8476
|
-
|
|
8477
|
-
|
|
8478
|
-
|
|
8479
|
-
|
|
8480
|
-
|
|
8481
|
-
|
|
8482
|
-
|
|
8483
|
-
|
|
8484
|
-
|
|
8414
|
+
|
|
8415
|
+
// src/modules/presence/types.ts
|
|
8416
|
+
var PRESENCE_CONTRACT = "v0";
|
|
8417
|
+
var DEFAULT_PORT = 27226;
|
|
8418
|
+
var PRESENCE_ENV = { port: "ARCANE_PRESENCE_PORT", token: "ARCANE_PRESENCE_TOKEN" };
|
|
8419
|
+
var LOOPBACK_HOST = "127.0.0.1";
|
|
8420
|
+
var MAX_BODY_BYTES = 65536;
|
|
8421
|
+
var MAX_LINE_CHARS = 256;
|
|
8422
|
+
var MAX_NEEDS_YOU = 20;
|
|
8423
|
+
var MAX_SUBSCRIBERS = 32;
|
|
8424
|
+
var MAX_SUBSCRIBER_BUFFER_BYTES = 1048576;
|
|
8425
|
+
var MAX_CONNECTIONS = 128;
|
|
8426
|
+
var MAX_SESSIONS = 256;
|
|
8427
|
+
var MAX_ALIASES = 32;
|
|
8428
|
+
var CONNECTIONS_CHECK_MS = 1e3;
|
|
8429
|
+
var HEADERS_TIMEOUT_MS = 5e3;
|
|
8430
|
+
var REQUEST_TIMEOUT_MS = 1e4;
|
|
8431
|
+
var STALE_AFTER_MS = 9e4;
|
|
8432
|
+
var EXPIRE_AFTER_MS = 864e5;
|
|
8433
|
+
var KEEPALIVE_MS = 15e3;
|
|
8434
|
+
var SWEEP_MS = 5e3;
|
|
8435
|
+
var HARNESSES = ["claude-code", "copilot", "codex", "openclaw", "cron", "other"];
|
|
8436
|
+
var REPORT_STATUSES = ["working", "waiting", "blocked", "idle"];
|
|
8437
|
+
var ALIAS_CONTEXTS = ["openclaw", "slug"];
|
|
8438
|
+
var SESSION_EVENTS = [
|
|
8439
|
+
"session.started",
|
|
8440
|
+
"session.updated",
|
|
8441
|
+
"session.needs_you",
|
|
8442
|
+
"session.stale",
|
|
8443
|
+
"session.resumed",
|
|
8444
|
+
"session.ended",
|
|
8445
|
+
"session.expired"
|
|
8446
|
+
];
|
|
8447
|
+
var HELLO_EVENT = "server.hello";
|
|
8448
|
+
function presenceCapabilities() {
|
|
8449
|
+
return { observe: true, actions: [] };
|
|
8450
|
+
}
|
|
8451
|
+
function nullAgentBlock() {
|
|
8452
|
+
return { ref: null, slug: null, name: null, origin: null, definition_revision: null, aliases: [] };
|
|
8453
|
+
}
|
|
8454
|
+
|
|
8455
|
+
// src/modules/presence/store.ts
|
|
8456
|
+
var PRESENCE_DIR_NAME = "presence";
|
|
8457
|
+
var STORE_FILES = { token: "token", serverInfo: "server.json" };
|
|
8458
|
+
var PresenceStoreError = class extends Error {
|
|
8459
|
+
};
|
|
8460
|
+
function home(options) {
|
|
8461
|
+
return options.home ?? resolveHomeDir();
|
|
8462
|
+
}
|
|
8463
|
+
function presenceDir(options = {}) {
|
|
8464
|
+
return join23(userTierRoot(home(options)), PRESENCE_DIR_NAME);
|
|
8465
|
+
}
|
|
8466
|
+
function tokenPath(options = {}) {
|
|
8467
|
+
return join23(presenceDir(options), STORE_FILES.token);
|
|
8468
|
+
}
|
|
8469
|
+
function serverInfoPath(options = {}) {
|
|
8470
|
+
return join23(presenceDir(options), STORE_FILES.serverInfo);
|
|
8471
|
+
}
|
|
8472
|
+
function ensureStore(options = {}) {
|
|
8473
|
+
const root = userTierRoot(home(options));
|
|
8474
|
+
const dir = presenceDir(options);
|
|
8475
|
+
mkdirSync2(root, { recursive: true });
|
|
8476
|
+
const realRoot = resolveRealPath(root);
|
|
8477
|
+
if (relative2(realRoot, resolveRealPath(dir)) !== PRESENCE_DIR_NAME) {
|
|
8478
|
+
throw new PresenceStoreError(`Refusing to use ${dir}: it resolves outside ${root}.`);
|
|
8485
8479
|
}
|
|
8486
|
-
|
|
8487
|
-
|
|
8488
|
-
const parts = [];
|
|
8489
|
-
if (offerable.length > 0) {
|
|
8490
|
-
parts.push(`${offerable.join(", ")} \u2014 \`spell update --add-new\` installs ${offerable.length === 1 ? "it" : "them"}`);
|
|
8480
|
+
if (relative2(realRoot, resolveRealPath(tokenPath(options))) !== join23(PRESENCE_DIR_NAME, STORE_FILES.token)) {
|
|
8481
|
+
throw new PresenceStoreError(`Refusing to use ${tokenPath(options)}: it resolves outside ${dir}.`);
|
|
8491
8482
|
}
|
|
8492
|
-
|
|
8493
|
-
|
|
8483
|
+
mkdirSync2(dir, { recursive: true, mode: 448 });
|
|
8484
|
+
chmodSync(dir, 448);
|
|
8485
|
+
return dir;
|
|
8486
|
+
}
|
|
8487
|
+
function writeRestricted(target, content, options) {
|
|
8488
|
+
const restrict = options.restrict ?? restrictToCurrentUser;
|
|
8489
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
8490
|
+
writeFileSync2(tmp, content, { encoding: "utf8", mode: 384 });
|
|
8491
|
+
try {
|
|
8492
|
+
restrict(tmp);
|
|
8493
|
+
} catch (error2) {
|
|
8494
|
+
rmSync2(tmp, { force: true });
|
|
8495
|
+
throw new PresenceStoreError(`Could not restrict ${target} to your account, so it was not written.`, { cause: error2 });
|
|
8494
8496
|
}
|
|
8495
|
-
|
|
8497
|
+
renameSync2(tmp, target);
|
|
8498
|
+
chmodSync(target, 384);
|
|
8496
8499
|
}
|
|
8497
|
-
|
|
8498
|
-
|
|
8499
|
-
|
|
8500
|
+
function generateToken() {
|
|
8501
|
+
return randomBytes2(32).toString("base64url");
|
|
8502
|
+
}
|
|
8503
|
+
function readTokenFile(options) {
|
|
8500
8504
|
let raw;
|
|
8501
8505
|
try {
|
|
8502
|
-
raw =
|
|
8506
|
+
raw = readFileSync3(tokenPath(options), "utf8");
|
|
8503
8507
|
} catch {
|
|
8504
|
-
return
|
|
8508
|
+
return null;
|
|
8505
8509
|
}
|
|
8506
|
-
|
|
8510
|
+
const token = raw.replace(/[\r\n]+$/, "").trim();
|
|
8511
|
+
return token.length > 0 ? token : null;
|
|
8512
|
+
}
|
|
8513
|
+
function ensureToken(options = {}) {
|
|
8514
|
+
ensureStore(options);
|
|
8515
|
+
const existing = options.rotate === true ? null : readTokenFile(options);
|
|
8516
|
+
if (existing !== null) {
|
|
8517
|
+
chmodSync(tokenPath(options), 384);
|
|
8518
|
+
(options.restrict ?? restrictToCurrentUser)(tokenPath(options));
|
|
8519
|
+
return existing;
|
|
8520
|
+
}
|
|
8521
|
+
const token = generateToken();
|
|
8522
|
+
writeRestricted(tokenPath(options), `${token}
|
|
8523
|
+
`, options);
|
|
8524
|
+
return token;
|
|
8525
|
+
}
|
|
8526
|
+
function readToken(options = {}) {
|
|
8527
|
+
const fromEnv = (options.env ?? process.env)[PRESENCE_ENV.token];
|
|
8528
|
+
if (fromEnv !== void 0 && fromEnv.replace(/[\r\n]+$/, "") !== "") return fromEnv.replace(/[\r\n]+$/, "");
|
|
8529
|
+
return readTokenFile(options);
|
|
8530
|
+
}
|
|
8531
|
+
function writeServerInfo(info, options = {}) {
|
|
8532
|
+
ensureStore(options);
|
|
8533
|
+
writeRestricted(serverInfoPath(options), `${JSON.stringify(info, null, 2)}
|
|
8534
|
+
`, options);
|
|
8535
|
+
}
|
|
8536
|
+
function readServerInfo(options = {}) {
|
|
8537
|
+
let parsed;
|
|
8507
8538
|
try {
|
|
8508
|
-
|
|
8539
|
+
parsed = JSON.parse(readFileSync3(serverInfoPath(options), "utf8"));
|
|
8509
8540
|
} catch {
|
|
8510
|
-
return
|
|
8511
|
-
name,
|
|
8512
|
-
passed: false,
|
|
8513
|
-
blocking: false,
|
|
8514
|
-
message: ".mcp.json exists but contains invalid JSON"
|
|
8515
|
-
};
|
|
8516
|
-
}
|
|
8517
|
-
if (!isRecord7(rawParsed)) {
|
|
8518
|
-
return {
|
|
8519
|
-
name,
|
|
8520
|
-
passed: false,
|
|
8521
|
-
blocking: false,
|
|
8522
|
-
message: ".mcp.json exists but is not a JSON object"
|
|
8523
|
-
};
|
|
8524
|
-
}
|
|
8525
|
-
const parsed = rawParsed;
|
|
8526
|
-
const servers = parsed.mcpServers ?? {};
|
|
8527
|
-
const serverNames = Object.keys(servers);
|
|
8528
|
-
if (serverNames.length === 0) {
|
|
8529
|
-
return { name, passed: true, blocking: false, message: "no MCP servers configured" };
|
|
8530
|
-
}
|
|
8531
|
-
const exampleHits = [];
|
|
8532
|
-
const placeholderHits = [];
|
|
8533
|
-
for (const n of serverNames) {
|
|
8534
|
-
const args = servers[n]?.args;
|
|
8535
|
-
if (!Array.isArray(args)) continue;
|
|
8536
|
-
for (const a of args) {
|
|
8537
|
-
if (typeof a !== "string") continue;
|
|
8538
|
-
if (a.startsWith(UNOWNED_EXAMPLE_SCOPE)) exampleHits.push(`${n} (${a})`);
|
|
8539
|
-
if (a === RETIRED_SCAFFOLD_PLACEHOLDER) placeholderHits.push(n);
|
|
8540
|
-
}
|
|
8541
|
+
return null;
|
|
8541
8542
|
}
|
|
8542
|
-
if (
|
|
8543
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
8544
|
+
const o = parsed;
|
|
8545
|
+
const generator = o["generator"];
|
|
8546
|
+
const valid = typeof o["url"] === "string" && Number.isInteger(o["pid"]) && o["pid"] > 0 && Number.isInteger(o["port"]) && o["port"] >= 1 && o["port"] <= 65535 && typeof o["started_at"] === "string" && typeof generator === "object" && generator !== null && generator["name"] === "arcane-cli" && typeof generator["version"] === "string";
|
|
8547
|
+
if (!valid) return null;
|
|
8548
|
+
return {
|
|
8549
|
+
url: o["url"],
|
|
8550
|
+
pid: o["pid"],
|
|
8551
|
+
port: o["port"],
|
|
8552
|
+
started_at: o["started_at"],
|
|
8553
|
+
generator: { name: "arcane-cli", version: generator["version"] }
|
|
8554
|
+
};
|
|
8555
|
+
}
|
|
8556
|
+
function removeServerInfo(options = {}) {
|
|
8557
|
+
rmSync2(serverInfoPath(options), { force: true });
|
|
8558
|
+
}
|
|
8559
|
+
function storeExists(options = {}) {
|
|
8560
|
+
return existsSync3(presenceDir(options));
|
|
8561
|
+
}
|
|
8562
|
+
|
|
8563
|
+
// src/modules/presence/client.ts
|
|
8564
|
+
var CONNECT_TIMEOUT_MS = 1e3;
|
|
8565
|
+
var RESPONSE_TIMEOUT_MS = 1500;
|
|
8566
|
+
function pidAlive(pid) {
|
|
8567
|
+
try {
|
|
8568
|
+
process.kill(pid, 0);
|
|
8569
|
+
return true;
|
|
8570
|
+
} catch {
|
|
8571
|
+
return false;
|
|
8572
|
+
}
|
|
8573
|
+
}
|
|
8574
|
+
function resolveEndpoint(options = {}) {
|
|
8575
|
+
const info = readServerInfo(options);
|
|
8576
|
+
if (info === null || !pidAlive(info.pid)) return null;
|
|
8577
|
+
const token = readToken({ ...options, env: options.env ?? process.env });
|
|
8578
|
+
if (token === null) return null;
|
|
8579
|
+
return { info, token };
|
|
8580
|
+
}
|
|
8581
|
+
function errorFields(body) {
|
|
8582
|
+
const e = body?.error;
|
|
8583
|
+
return { code: typeof e?.code === "string" ? e.code : null, field: typeof e?.field === "string" ? e.field : null };
|
|
8584
|
+
}
|
|
8585
|
+
function send(port, token, method, path5, body, options = {}) {
|
|
8586
|
+
const connectTimeout = options.connectTimeoutMs ?? CONNECT_TIMEOUT_MS;
|
|
8587
|
+
const responseTimeout = options.responseTimeoutMs ?? RESPONSE_TIMEOUT_MS;
|
|
8588
|
+
const payload = body === void 0 ? void 0 : JSON.stringify(body);
|
|
8589
|
+
return new Promise((resolve10) => {
|
|
8590
|
+
let settled = false;
|
|
8591
|
+
const done = (result) => {
|
|
8592
|
+
if (settled) return;
|
|
8593
|
+
settled = true;
|
|
8594
|
+
clearTimeout(connectTimer);
|
|
8595
|
+
clearTimeout(responseTimer);
|
|
8596
|
+
resolve10(result);
|
|
8597
|
+
};
|
|
8598
|
+
const headers = { Authorization: `Bearer ${token}`, Accept: "application/json" };
|
|
8599
|
+
if (payload !== void 0) {
|
|
8600
|
+
headers["Content-Type"] = "application/json";
|
|
8601
|
+
headers["Content-Length"] = Buffer.byteLength(payload);
|
|
8602
|
+
}
|
|
8603
|
+
const req = request({ host: LOOPBACK_HOST, port, method, path: path5, headers, agent: false }, (res) => {
|
|
8604
|
+
const chunks = [];
|
|
8605
|
+
res.on("data", (c) => chunks.push(c));
|
|
8606
|
+
res.on("error", () => done({ kind: "unreachable" }));
|
|
8607
|
+
res.on("end", () => {
|
|
8608
|
+
const status = res.statusCode ?? 0;
|
|
8609
|
+
const text2 = Buffer.concat(chunks).toString("utf8");
|
|
8610
|
+
let parsed = null;
|
|
8611
|
+
try {
|
|
8612
|
+
parsed = text2.length > 0 ? JSON.parse(text2) : null;
|
|
8613
|
+
} catch {
|
|
8614
|
+
parsed = null;
|
|
8615
|
+
}
|
|
8616
|
+
if (status >= 200 && status < 300 && parsed !== null) done({ kind: "ok", status, body: parsed });
|
|
8617
|
+
else if (status === 401 || status === 400 || status === 404 || status === 413) done({ kind: "refused", status, ...errorFields(parsed) });
|
|
8618
|
+
else done({ kind: "unreachable" });
|
|
8619
|
+
});
|
|
8620
|
+
});
|
|
8621
|
+
const connectTimer = setTimeout(() => {
|
|
8622
|
+
req.destroy();
|
|
8623
|
+
done({ kind: "unreachable" });
|
|
8624
|
+
}, connectTimeout);
|
|
8625
|
+
const responseTimer = setTimeout(() => {
|
|
8626
|
+
req.destroy();
|
|
8627
|
+
done({ kind: "unreachable" });
|
|
8628
|
+
}, connectTimeout + responseTimeout);
|
|
8629
|
+
req.on("socket", (socket) => {
|
|
8630
|
+
socket.once("connect", () => clearTimeout(connectTimer));
|
|
8631
|
+
});
|
|
8632
|
+
req.on("error", () => done({ kind: "unreachable" }));
|
|
8633
|
+
if (payload !== void 0) req.write(payload);
|
|
8634
|
+
req.end();
|
|
8635
|
+
});
|
|
8636
|
+
}
|
|
8637
|
+
async function callPresence(method, path5, body, options = {}) {
|
|
8638
|
+
const endpoint = resolveEndpoint(options);
|
|
8639
|
+
if (endpoint === null) return { kind: "not-configured" };
|
|
8640
|
+
return send(endpoint.info.port, endpoint.token, method, path5, body, options);
|
|
8641
|
+
}
|
|
8642
|
+
function openEventFeed(callbacks, options = {}) {
|
|
8643
|
+
const endpoint = resolveEndpoint(options);
|
|
8644
|
+
if (endpoint === null) return Promise.resolve(null);
|
|
8645
|
+
const deadline = (options.connectTimeoutMs ?? CONNECT_TIMEOUT_MS) + (options.responseTimeoutMs ?? RESPONSE_TIMEOUT_MS);
|
|
8646
|
+
return new Promise((resolve10) => {
|
|
8647
|
+
let opened = false;
|
|
8648
|
+
let settled = false;
|
|
8649
|
+
let closedHere = false;
|
|
8650
|
+
let closeReported = false;
|
|
8651
|
+
const settle = (feed2) => {
|
|
8652
|
+
if (settled) return;
|
|
8653
|
+
settled = true;
|
|
8654
|
+
clearTimeout(timer);
|
|
8655
|
+
if (feed2 === null) req.destroy();
|
|
8656
|
+
resolve10(feed2);
|
|
8657
|
+
};
|
|
8658
|
+
const ended = () => {
|
|
8659
|
+
if (!opened) {
|
|
8660
|
+
settle(null);
|
|
8661
|
+
return;
|
|
8662
|
+
}
|
|
8663
|
+
if (closedHere || closeReported) return;
|
|
8664
|
+
closeReported = true;
|
|
8665
|
+
callbacks.onClose();
|
|
8666
|
+
};
|
|
8667
|
+
const feed = {
|
|
8668
|
+
close() {
|
|
8669
|
+
closedHere = true;
|
|
8670
|
+
req.destroy();
|
|
8671
|
+
}
|
|
8672
|
+
};
|
|
8673
|
+
const req = request(
|
|
8674
|
+
{
|
|
8675
|
+
host: LOOPBACK_HOST,
|
|
8676
|
+
port: endpoint.info.port,
|
|
8677
|
+
method: "GET",
|
|
8678
|
+
path: "/v0/events",
|
|
8679
|
+
headers: { Authorization: `Bearer ${endpoint.token}`, Accept: "text/event-stream" },
|
|
8680
|
+
agent: false
|
|
8681
|
+
},
|
|
8682
|
+
(res) => {
|
|
8683
|
+
if (res.statusCode !== 200) {
|
|
8684
|
+
res.resume();
|
|
8685
|
+
settle(null);
|
|
8686
|
+
return;
|
|
8687
|
+
}
|
|
8688
|
+
res.setEncoding("utf8");
|
|
8689
|
+
let buffer = "";
|
|
8690
|
+
res.on("data", (chunk) => {
|
|
8691
|
+
buffer += chunk.replace(/\r\n?/g, "\n");
|
|
8692
|
+
let at;
|
|
8693
|
+
while ((at = buffer.indexOf("\n\n")) >= 0) {
|
|
8694
|
+
const block = buffer.slice(0, at);
|
|
8695
|
+
buffer = buffer.slice(at + 2);
|
|
8696
|
+
const name = /^event: ?(.*)$/m.exec(block)?.[1];
|
|
8697
|
+
const data = /^data: ?(.*)$/m.exec(block)?.[1];
|
|
8698
|
+
if (name === void 0 || data === void 0) continue;
|
|
8699
|
+
let parsed;
|
|
8700
|
+
try {
|
|
8701
|
+
parsed = JSON.parse(data);
|
|
8702
|
+
} catch {
|
|
8703
|
+
continue;
|
|
8704
|
+
}
|
|
8705
|
+
if (!opened) {
|
|
8706
|
+
if (name !== "server.hello") {
|
|
8707
|
+
settle(null);
|
|
8708
|
+
return;
|
|
8709
|
+
}
|
|
8710
|
+
opened = true;
|
|
8711
|
+
settle(feed);
|
|
8712
|
+
continue;
|
|
8713
|
+
}
|
|
8714
|
+
if (!closedHere) callbacks.onEvent(name, parsed);
|
|
8715
|
+
}
|
|
8716
|
+
});
|
|
8717
|
+
res.on("end", ended);
|
|
8718
|
+
res.on("close", ended);
|
|
8719
|
+
res.on("error", ended);
|
|
8720
|
+
}
|
|
8721
|
+
);
|
|
8722
|
+
const timer = setTimeout(() => settle(null), deadline);
|
|
8723
|
+
req.on("error", ended);
|
|
8724
|
+
req.end();
|
|
8725
|
+
});
|
|
8726
|
+
}
|
|
8727
|
+
|
|
8728
|
+
// src/commands/doctor.ts
|
|
8729
|
+
import { statSync } from "fs";
|
|
8730
|
+
function isRecord7(value) {
|
|
8731
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8732
|
+
}
|
|
8733
|
+
async function checkNodeVersion() {
|
|
8734
|
+
const name = "Node.js version";
|
|
8735
|
+
const raw = process.version;
|
|
8736
|
+
const major = parseInt(raw.replace(/^v/, "").split(".")[0] ?? "0", 10);
|
|
8737
|
+
if (major >= 18) {
|
|
8738
|
+
return { name, passed: true, message: `${raw} (>=18 required)` };
|
|
8739
|
+
}
|
|
8740
|
+
return {
|
|
8741
|
+
name,
|
|
8742
|
+
passed: false,
|
|
8743
|
+
message: `${raw} \u2014 Node.js 18 or later is required`
|
|
8744
|
+
};
|
|
8745
|
+
}
|
|
8746
|
+
async function checkArcaneManifest(targetDir) {
|
|
8747
|
+
const name = "Arcane manifest (.arcane.json)";
|
|
8748
|
+
const manifestPath2 = join24(targetDir, ".arcane.json");
|
|
8749
|
+
try {
|
|
8750
|
+
await access4(manifestPath2);
|
|
8751
|
+
} catch {
|
|
8752
|
+
const selfHostedManifestPath = join24(targetDir, "src", "assets", ".arcane.json");
|
|
8753
|
+
try {
|
|
8754
|
+
const raw = await readFile19(selfHostedManifestPath, "utf8");
|
|
8755
|
+
const manifest = JSON.parse(raw);
|
|
8756
|
+
if (isRecord7(manifest) && manifest["selfHosted"] === true && manifest["tracking_mode"] === "internal") {
|
|
8757
|
+
return {
|
|
8758
|
+
name,
|
|
8759
|
+
passed: true,
|
|
8760
|
+
message: "self-hosted source tree \u2014 committed source manifest is authoritative"
|
|
8761
|
+
};
|
|
8762
|
+
}
|
|
8763
|
+
} catch {
|
|
8764
|
+
}
|
|
8543
8765
|
return {
|
|
8544
8766
|
name,
|
|
8545
8767
|
passed: false,
|
|
8546
|
-
|
|
8547
|
-
|
|
8768
|
+
message: "No .arcane.json found. Run `spell init` to initialize Arcane in this project.",
|
|
8769
|
+
blocking: false
|
|
8548
8770
|
};
|
|
8549
8771
|
}
|
|
8550
|
-
|
|
8772
|
+
try {
|
|
8773
|
+
const raw = await readFile19(manifestPath2, "utf8");
|
|
8774
|
+
const manifest = JSON.parse(raw);
|
|
8775
|
+
if (!isRecord7(manifest) || typeof manifest["version"] !== "string" || !manifest["version"] || !Array.isArray(manifest["components"])) {
|
|
8776
|
+
return {
|
|
8777
|
+
name,
|
|
8778
|
+
passed: false,
|
|
8779
|
+
message: ".arcane.json is present but missing required fields (version, components). Re-run `spell init`."
|
|
8780
|
+
};
|
|
8781
|
+
}
|
|
8551
8782
|
return {
|
|
8552
8783
|
name,
|
|
8553
|
-
passed:
|
|
8554
|
-
|
|
8555
|
-
message: `.mcp.json still holds the placeholder server from the retired mcp-config-template scaffold: ${placeholderHits.join(", ")} (${RETIRED_SCAFFOLD_PLACEHOLDER}). Clients load this file as live MCP config, so the placeholder fails on every session start. Delete the file if you run no MCP servers, or replace the entry with a real server package; the file is yours, so doctor does not edit it. An untouched scaffold is removed by \`spell update --prune\``
|
|
8784
|
+
passed: true,
|
|
8785
|
+
message: `v${manifest["version"]} \u2014 ${manifest["components"].length} component(s) installed`
|
|
8556
8786
|
};
|
|
8557
|
-
}
|
|
8558
|
-
const missingTimeout = serverNames.filter((n) => typeof servers[n]?.timeout !== "number");
|
|
8559
|
-
if (missingTimeout.length > 0) {
|
|
8787
|
+
} catch {
|
|
8560
8788
|
return {
|
|
8561
8789
|
name,
|
|
8562
8790
|
passed: false,
|
|
8563
|
-
|
|
8564
|
-
message: `server(s) missing a "timeout": ${missingTimeout.join(", ")} \u2014 add "timeout": 30000 (milliseconds; Claude Code ignores a value below 1000) to each entry under mcpServers, so a stuck tool call ends after 30 s instead of running to the client's own idle limit (as long as 30 minutes)`
|
|
8791
|
+
message: ".arcane.json is present but contains invalid JSON. Re-run `spell init`."
|
|
8565
8792
|
};
|
|
8566
8793
|
}
|
|
8567
|
-
return {
|
|
8568
|
-
name,
|
|
8569
|
-
passed: true,
|
|
8570
|
-
blocking: false,
|
|
8571
|
-
message: `${serverNames.length} server(s) configured, all with a timeout set`
|
|
8572
|
-
};
|
|
8573
|
-
}
|
|
8574
|
-
function isDelegation(value) {
|
|
8575
|
-
return isRecord7(value) && typeof value["id"] === "string" && typeof value["status"] === "string" && typeof value["scope"] === "string" && Array.isArray(value["excludedActions"]);
|
|
8576
|
-
}
|
|
8577
|
-
function isDelegationsFile(value) {
|
|
8578
|
-
return isRecord7(value) && Array.isArray(value["delegations"]) && value["delegations"].every(isDelegation);
|
|
8579
8794
|
}
|
|
8580
|
-
async function
|
|
8581
|
-
const name = "
|
|
8582
|
-
const filePath = join22(targetDir, ".arcane", "delegations.json");
|
|
8583
|
-
let raw;
|
|
8584
|
-
try {
|
|
8585
|
-
raw = await readFile19(filePath, "utf8");
|
|
8586
|
-
} catch {
|
|
8587
|
-
return { name, passed: true, blocking: false, message: "no delegations recorded" };
|
|
8588
|
-
}
|
|
8589
|
-
let rawParsed;
|
|
8795
|
+
async function checkPullRebase(targetDir) {
|
|
8796
|
+
const name = "Git pull.rebase (rebase-and-fast-forward mandate)";
|
|
8590
8797
|
try {
|
|
8591
|
-
|
|
8592
|
-
|
|
8798
|
+
const { stdout } = await runGit(targetDir, ["config", "--type=bool", "--get", "pull.rebase"]);
|
|
8799
|
+
const value = stdout.trim();
|
|
8800
|
+
if (value === "true") {
|
|
8801
|
+
return { name, passed: true, message: "pull.rebase=true" };
|
|
8802
|
+
}
|
|
8593
8803
|
return {
|
|
8594
8804
|
name,
|
|
8595
8805
|
passed: false,
|
|
8596
|
-
|
|
8597
|
-
|
|
8806
|
+
message: `pull.rebase resolves to "${value}", not "true" -- a bare \`git pull\` will create merge commits, contradicting git-conventions.md's rebase-and-fast-forward mandate. Run: git config --local pull.rebase true`,
|
|
8807
|
+
blocking: false
|
|
8598
8808
|
};
|
|
8599
|
-
}
|
|
8600
|
-
if (!isDelegationsFile(rawParsed)) {
|
|
8809
|
+
} catch {
|
|
8601
8810
|
return {
|
|
8602
8811
|
name,
|
|
8603
8812
|
passed: false,
|
|
8604
|
-
|
|
8605
|
-
|
|
8813
|
+
message: "pull.rebase is unset, not a recognized boolean, or this isn't a Git repository -- effective behavior may not be rebase-and-fast-forward, and Git for Windows defaults this to false when unset. Run: git config --local pull.rebase true",
|
|
8814
|
+
blocking: false
|
|
8606
8815
|
};
|
|
8607
8816
|
}
|
|
8608
|
-
|
|
8609
|
-
|
|
8610
|
-
|
|
8611
|
-
|
|
8817
|
+
}
|
|
8818
|
+
function checkIncidentReleaseGate() {
|
|
8819
|
+
const result = evaluateIncidentGate(INCIDENT_QUEUE);
|
|
8820
|
+
if (result.blocked) {
|
|
8821
|
+
return {
|
|
8822
|
+
name: "Incident release gate (ARC-024)",
|
|
8823
|
+
passed: false,
|
|
8824
|
+
message: result.blockers.join("; ")
|
|
8825
|
+
};
|
|
8612
8826
|
}
|
|
8613
|
-
const
|
|
8827
|
+
const accepted = result.acceptedRisks.length > 0 ? `; ${result.acceptedRisks.length} explicitly deferred` : "";
|
|
8614
8828
|
return {
|
|
8615
|
-
name,
|
|
8829
|
+
name: "Incident release gate (ARC-024)",
|
|
8616
8830
|
passed: true,
|
|
8617
|
-
|
|
8618
|
-
message: `${active.length} active: ${summary}`
|
|
8831
|
+
message: `${result.checked} incidents classified${accepted}`
|
|
8619
8832
|
};
|
|
8620
8833
|
}
|
|
8621
|
-
async function
|
|
8622
|
-
const name = "
|
|
8623
|
-
const
|
|
8624
|
-
const
|
|
8625
|
-
|
|
8626
|
-
|
|
8627
|
-
|
|
8628
|
-
}
|
|
8629
|
-
|
|
8630
|
-
|
|
8631
|
-
|
|
8632
|
-
|
|
8633
|
-
|
|
8834
|
+
async function checkSecrets(targetDir) {
|
|
8835
|
+
const name = "Secrets scan (ARC-037)";
|
|
8836
|
+
const excludePrefixes = await resolveSecretsScanExcludePrefixes(targetDir);
|
|
8837
|
+
const findings = await scanRepository(targetDir, SECRETS_RULES, void 0, excludePrefixes);
|
|
8838
|
+
if (findings.length === 0) {
|
|
8839
|
+
return { name, passed: true, message: "no credential-shaped patterns found" };
|
|
8840
|
+
}
|
|
8841
|
+
const summary = findings.map((f) => `${f.file}:${f.line} [${f.rule}]`).join("; ");
|
|
8842
|
+
return {
|
|
8843
|
+
name,
|
|
8844
|
+
passed: false,
|
|
8845
|
+
message: `${findings.length} credential-shaped pattern(s) found: ${summary}`
|
|
8846
|
+
};
|
|
8847
|
+
}
|
|
8848
|
+
var SESSION_CONTINUITY_FILES = [
|
|
8849
|
+
"TODO.md",
|
|
8850
|
+
"DECISIONS.md",
|
|
8851
|
+
"ai-context/system-prompt-context.md",
|
|
8852
|
+
"journal/.gitkeep"
|
|
8853
|
+
];
|
|
8854
|
+
async function checkSessionContinuity(targetDir) {
|
|
8855
|
+
const results = [];
|
|
8856
|
+
for (const file of SESSION_CONTINUITY_FILES) {
|
|
8857
|
+
const name = `Session file: ${file}`;
|
|
8858
|
+
const filePath = join24(targetDir, file);
|
|
8859
|
+
try {
|
|
8860
|
+
await access4(filePath);
|
|
8861
|
+
results.push({ name, passed: true, message: "present" });
|
|
8862
|
+
} catch {
|
|
8863
|
+
results.push({
|
|
8634
8864
|
name,
|
|
8635
8865
|
passed: false,
|
|
8866
|
+
message: `Missing. Required for spell-close-session. Run \`spell doctor --fix\` to create it.`,
|
|
8867
|
+
blocking: false
|
|
8868
|
+
});
|
|
8869
|
+
}
|
|
8870
|
+
}
|
|
8871
|
+
return results;
|
|
8872
|
+
}
|
|
8873
|
+
async function fixSessionContinuity(targetDir, assetsDir) {
|
|
8874
|
+
const created = [];
|
|
8875
|
+
for (const file of SESSION_CONTINUITY_FILES) {
|
|
8876
|
+
const destPath = join24(targetDir, file);
|
|
8877
|
+
try {
|
|
8878
|
+
await access4(destPath);
|
|
8879
|
+
} catch {
|
|
8880
|
+
const escape = findLinkedEscape(targetDir, dirname12(file));
|
|
8881
|
+
if (escape !== null) {
|
|
8882
|
+
console.log(
|
|
8883
|
+
` ! Not created: ${file} \u2014 it is under "${escape.link}", a link to "${escape.resolvesTo}" outside "${resolve7(targetDir)}".`
|
|
8884
|
+
);
|
|
8885
|
+
continue;
|
|
8886
|
+
}
|
|
8887
|
+
const fileEscape = findLinkedFileEscape(targetDir, file);
|
|
8888
|
+
if (fileEscape !== null) {
|
|
8889
|
+
console.log(
|
|
8890
|
+
` ! Not created: ${file} \u2014 it is a link to "${fileEscape.resolvesTo}" outside "${resolve7(targetDir)}".`
|
|
8891
|
+
);
|
|
8892
|
+
continue;
|
|
8893
|
+
}
|
|
8894
|
+
const srcPath = join24(assetsDir, file);
|
|
8895
|
+
await mkdir7(dirname12(destPath), { recursive: true });
|
|
8896
|
+
await fsCopyFile(srcPath, destPath);
|
|
8897
|
+
created.push(file);
|
|
8898
|
+
}
|
|
8899
|
+
}
|
|
8900
|
+
return created;
|
|
8901
|
+
}
|
|
8902
|
+
async function checkUserTier(homeDir = resolveHomeDir(), cliVersion) {
|
|
8903
|
+
const name = "User tier (ARC-045)";
|
|
8904
|
+
const storeRoot = userTierRoot(homeDir);
|
|
8905
|
+
let manifest;
|
|
8906
|
+
try {
|
|
8907
|
+
manifest = await readManifest(storeRoot);
|
|
8908
|
+
} catch (err) {
|
|
8909
|
+
if (err instanceof ManifestNotFoundError) {
|
|
8910
|
+
return {
|
|
8911
|
+
name,
|
|
8912
|
+
passed: true,
|
|
8636
8913
|
blocking: false,
|
|
8637
|
-
message:
|
|
8914
|
+
message: "no user tier installed (optional \u2014 `spell init --user` shares every spell across the repositories on this machine)"
|
|
8638
8915
|
};
|
|
8639
8916
|
}
|
|
8640
|
-
|
|
8641
|
-
|
|
8917
|
+
return {
|
|
8918
|
+
name,
|
|
8919
|
+
passed: false,
|
|
8920
|
+
blocking: false,
|
|
8921
|
+
message: `could not read ${storeRoot}/.arcane.json (${err instanceof Error ? err.message : String(err)}) \u2014 remove it and re-run \`spell init --user\``
|
|
8922
|
+
};
|
|
8642
8923
|
}
|
|
8643
|
-
|
|
8644
|
-
|
|
8645
|
-
const policies = await fetchAdoMergeTypePolicies(ado.org, ado.project, ado.repo, "main");
|
|
8646
|
-
if (policies === null) {
|
|
8924
|
+
try {
|
|
8925
|
+
if (typeof manifest.version !== "string" || manifest.version.length === 0) {
|
|
8647
8926
|
return {
|
|
8648
8927
|
name,
|
|
8649
8928
|
passed: false,
|
|
8650
8929
|
blocking: false,
|
|
8651
|
-
message:
|
|
8930
|
+
message: `${storeRoot}/.arcane.json has no version field \u2014 remove the store and re-run \`spell init --user\``
|
|
8652
8931
|
};
|
|
8653
8932
|
}
|
|
8654
|
-
const
|
|
8655
|
-
|
|
8933
|
+
const majorMinor = (v) => v.split(".").slice(0, 2).join(".");
|
|
8934
|
+
const problems = [];
|
|
8935
|
+
if (cliVersion !== void 0 && majorMinor(manifest.version) !== majorMinor(cliVersion)) {
|
|
8936
|
+
problems.push(`installed at v${manifest.version}, this CLI is v${cliVersion}`);
|
|
8937
|
+
}
|
|
8938
|
+
const health = await inspectUserTierFanout(homeDir, manifest.fanout);
|
|
8939
|
+
if (health.missing.length > 0) {
|
|
8940
|
+
problems.push(`${health.missing.length} of ${health.total} client file(s) missing`);
|
|
8941
|
+
}
|
|
8942
|
+
if (problems.length > 0) {
|
|
8943
|
+
return {
|
|
8944
|
+
name,
|
|
8945
|
+
passed: false,
|
|
8946
|
+
blocking: false,
|
|
8947
|
+
message: `${problems.join("; ")} \u2014 run \`spell update --user\` (regenerates missing files; never overwrites edited ones)`
|
|
8948
|
+
};
|
|
8949
|
+
}
|
|
8950
|
+
const customized = health.customized.length > 0 ? `, ${health.customized.length} customized` : "";
|
|
8951
|
+
return {
|
|
8952
|
+
name,
|
|
8953
|
+
passed: true,
|
|
8954
|
+
blocking: false,
|
|
8955
|
+
message: `v${manifest.version} at ${storeRoot} \u2014 ${health.total} client file(s) in place${customized}`
|
|
8956
|
+
};
|
|
8957
|
+
} catch (err) {
|
|
8958
|
+
return {
|
|
8959
|
+
name,
|
|
8960
|
+
passed: false,
|
|
8961
|
+
blocking: false,
|
|
8962
|
+
message: `could not inspect the user tier at ${storeRoot} (${err instanceof Error ? err.message : String(err)}) \u2014 run \`spell update --user\`; if that fails too, remove the store and re-run \`spell init --user\``
|
|
8963
|
+
};
|
|
8656
8964
|
}
|
|
8657
|
-
return {
|
|
8658
|
-
name,
|
|
8659
|
-
passed: true,
|
|
8660
|
-
blocking: false,
|
|
8661
|
-
message: "remote is neither GitHub nor Azure DevOps \u2014 platform policy verification not available for this provider"
|
|
8662
|
-
};
|
|
8663
8965
|
}
|
|
8664
|
-
async function
|
|
8665
|
-
const name = "
|
|
8666
|
-
|
|
8667
|
-
|
|
8668
|
-
manifest = await readManifest(targetDir);
|
|
8669
|
-
} catch {
|
|
8670
|
-
return { name, passed: true, skipped: true, message: "no .arcane.json \u2014 nothing to check" };
|
|
8671
|
-
}
|
|
8672
|
-
try {
|
|
8673
|
-
await runGit(targetDir, ["rev-parse", "--git-dir"]);
|
|
8674
|
-
} catch {
|
|
8675
|
-
return { name, passed: true, skipped: true, message: "not a git repository \u2014 the index cannot be read" };
|
|
8966
|
+
async function checkPresenceService(options = {}) {
|
|
8967
|
+
const name = "Presence service (ARC-060)";
|
|
8968
|
+
if (!storeExists(options)) {
|
|
8969
|
+
return { name, passed: true, blocking: false, message: "no presence channel on this machine (optional \u2014 `spell presence serve` starts one)" };
|
|
8676
8970
|
}
|
|
8677
|
-
const
|
|
8678
|
-
const
|
|
8679
|
-
|
|
8680
|
-
const pathBodies = [];
|
|
8681
|
-
for (const file of managed) {
|
|
8682
|
-
if (!isClientShimPath(file) || asLinks.includes(file)) continue;
|
|
8683
|
-
let body;
|
|
8971
|
+
const alive = options.pidAlive ?? pidAlive;
|
|
8972
|
+
const problems = [];
|
|
8973
|
+
if ((options.platform ?? process.platform) !== "win32") {
|
|
8684
8974
|
try {
|
|
8685
|
-
|
|
8975
|
+
const mode = statSync(tokenPath(options)).mode & 511;
|
|
8976
|
+
if ((mode & 63) !== 0) {
|
|
8977
|
+
problems.push(`the token file ${tokenPath(options)} has mode ${mode.toString(8).padStart(4, "0")}; run \`chmod 600\` on it, or restart \`spell presence serve\`, which re-sets it`);
|
|
8978
|
+
}
|
|
8686
8979
|
} catch {
|
|
8687
|
-
continue;
|
|
8688
8980
|
}
|
|
8689
|
-
if (looksLikeBarePath(body)) pathBodies.push(file);
|
|
8690
8981
|
}
|
|
8691
|
-
|
|
8692
|
-
|
|
8982
|
+
const warn = (state) => ({ name, passed: false, blocking: false, message: [state, ...problems].join("; ") });
|
|
8983
|
+
let recorded;
|
|
8984
|
+
try {
|
|
8985
|
+
statSync(serverInfoPath(options));
|
|
8986
|
+
recorded = true;
|
|
8987
|
+
} catch {
|
|
8988
|
+
recorded = false;
|
|
8693
8989
|
}
|
|
8694
|
-
|
|
8695
|
-
|
|
8696
|
-
parts.push(`${asLinks.length} tracked as a symlink (mode 120000): ${asLinks.join(", ")}`);
|
|
8990
|
+
if (!recorded) {
|
|
8991
|
+
return problems.length > 0 ? warn("not running") : { name, passed: true, blocking: false, message: `not running (store at ${presenceDir(options)})` };
|
|
8697
8992
|
}
|
|
8698
|
-
|
|
8699
|
-
|
|
8993
|
+
const info = readServerInfo(options);
|
|
8994
|
+
if (info === null) return warn(`${serverInfoPath(options)} is not a record this CLI wrote; remove it if no service is running`);
|
|
8995
|
+
if (!alive(info.pid)) {
|
|
8996
|
+
return warn(`stale: ${serverInfoPath(options)} names pid ${info.pid}, which is not running \u2014 \`spell doctor --fix\` removes the file (the token stays)`);
|
|
8700
8997
|
}
|
|
8701
|
-
|
|
8702
|
-
|
|
8703
|
-
|
|
8704
|
-
|
|
8705
|
-
|
|
8998
|
+
const health = await callPresence("GET", "/v0/health", void 0, { ...options, env: options.env ?? process.env });
|
|
8999
|
+
if (health.kind === "ok") {
|
|
9000
|
+
const body = health.body;
|
|
9001
|
+
const version = typeof body.generator?.version === "string" ? `v${body.generator.version}` : "unknown version";
|
|
9002
|
+
const sessions = typeof body.sessions === "number" ? `, ${body.sessions} session(s)` : "";
|
|
9003
|
+
if (problems.length > 0) return warn(`reachable at ${info.url}`);
|
|
9004
|
+
return { name, passed: true, blocking: false, message: `reachable at ${info.url} (pid ${info.pid}, ${version}${sessions})` };
|
|
9005
|
+
}
|
|
9006
|
+
if (health.kind === "refused" && health.status === 401) {
|
|
9007
|
+
return warn(`the service at ${info.url} refused this machine's token (rotated, or ARCANE_PRESENCE_TOKEN is set to another value)`);
|
|
9008
|
+
}
|
|
9009
|
+
return warn(`not reachable: ${info.url} (pid ${info.pid} is running but does not answer)`);
|
|
8706
9010
|
}
|
|
8707
|
-
|
|
8708
|
-
const
|
|
9011
|
+
function fixPresenceService(options = {}) {
|
|
9012
|
+
const info = readServerInfo(options);
|
|
9013
|
+
if (info === null || (options.pidAlive ?? pidAlive)(info.pid)) return null;
|
|
9014
|
+
removeServerInfo(options);
|
|
9015
|
+
return serverInfoPath(options);
|
|
9016
|
+
}
|
|
9017
|
+
async function checkPushPolicy(targetDir) {
|
|
9018
|
+
const name = "Push policy (EF-09)";
|
|
8709
9019
|
let manifest;
|
|
8710
9020
|
try {
|
|
8711
9021
|
manifest = await readManifest(targetDir);
|
|
8712
9022
|
} catch {
|
|
8713
|
-
return { name, passed: true,
|
|
9023
|
+
return { name, passed: true, message: "not initialized \u2014 nothing to check" };
|
|
8714
9024
|
}
|
|
8715
|
-
|
|
9025
|
+
const policy = manifest.push_policy ?? "open";
|
|
9026
|
+
if (policy === "open") {
|
|
9027
|
+
return { name, passed: true, message: "open \u2014 pushes allowed" };
|
|
9028
|
+
}
|
|
9029
|
+
if (policy === "guarded") {
|
|
9030
|
+
const targets = await pushTargets(targetDir);
|
|
9031
|
+
const where = targets.length === 0 ? "none configured" : targets.map(({ remote, urls }) => `${remote} \u2192 ${urls.length > 0 ? urls.join(", ") : "unknown"}`).join("; ");
|
|
8716
9032
|
return {
|
|
8717
9033
|
name,
|
|
8718
|
-
passed:
|
|
9034
|
+
passed: false,
|
|
8719
9035
|
blocking: false,
|
|
8720
|
-
message:
|
|
9036
|
+
message: `guarded \u2014 no technical control installed. Push targets: ${where}. Confirm those are the remotes you intend before pushing.`
|
|
8721
9037
|
};
|
|
8722
9038
|
}
|
|
8723
|
-
const
|
|
8724
|
-
|
|
8725
|
-
|
|
8726
|
-
|
|
8727
|
-
|
|
9039
|
+
const hookActive = await isHookEnforced(targetDir);
|
|
9040
|
+
const remotes = await listRemotes(targetDir);
|
|
9041
|
+
const open = await undisabledRemotes(targetDir);
|
|
9042
|
+
if (hookActive && open.length === 0) {
|
|
9043
|
+
if (remotes.length === 0) {
|
|
9044
|
+
return {
|
|
9045
|
+
name,
|
|
9046
|
+
passed: false,
|
|
9047
|
+
blocking: false,
|
|
9048
|
+
message: "blocked \u2014 pre-push hook in place, but no remote is configured, so no push URL is disabled. If a remote is added later, run `spell doctor` again: a --no-verify push to it would succeed until the URL is disabled too."
|
|
9049
|
+
};
|
|
9050
|
+
}
|
|
8728
9051
|
return {
|
|
8729
9052
|
name,
|
|
8730
|
-
passed:
|
|
8731
|
-
message: `
|
|
9053
|
+
passed: true,
|
|
9054
|
+
message: `blocked \u2014 pre-push hook in place and push URL disabled for ${remotes.join(", ")}`
|
|
8732
9055
|
};
|
|
8733
9056
|
}
|
|
8734
|
-
|
|
8735
|
-
|
|
8736
|
-
|
|
9057
|
+
const missing = [
|
|
9058
|
+
hookActive ? null : "pre-push hook (config or hook file absent)",
|
|
9059
|
+
open.length > 0 ? `push URL still live for: ${open.join(", ")}` : null
|
|
9060
|
+
].filter(Boolean);
|
|
9061
|
+
return {
|
|
9062
|
+
name,
|
|
9063
|
+
passed: false,
|
|
9064
|
+
blocking: false,
|
|
9065
|
+
// ARC-049 decision 3: name the command that closes the gap.
|
|
9066
|
+
message: `declared "blocked" but not enforced \u2014 ${missing.join("; ")}. The manifest claims a protection this repository does not have. Run \`spell block-push\` to install the controls.`
|
|
9067
|
+
};
|
|
9068
|
+
}
|
|
9069
|
+
async function checkGovernancePlaceholders(targetDir) {
|
|
9070
|
+
const name = "Governance placeholders (ARC-051)";
|
|
9071
|
+
let scan;
|
|
8737
9072
|
try {
|
|
8738
|
-
|
|
8739
|
-
|
|
8740
|
-
spellCount = spells.length;
|
|
8741
|
-
probed = spells.includes(probe2);
|
|
8742
|
-
} catch {
|
|
8743
|
-
}
|
|
8744
|
-
if (spellCount === 0 || !probed) {
|
|
9073
|
+
scan = await scanGovernancePlaceholders(join24(targetDir, ".arcane", "governance"));
|
|
9074
|
+
} catch (err) {
|
|
8745
9075
|
return {
|
|
8746
9076
|
name,
|
|
8747
9077
|
passed: false,
|
|
8748
|
-
|
|
9078
|
+
blocking: false,
|
|
9079
|
+
message: `could not scan .arcane/governance (${err instanceof Error ? err.message : String(err)})`
|
|
8749
9080
|
};
|
|
8750
9081
|
}
|
|
8751
|
-
|
|
8752
|
-
|
|
9082
|
+
if (scan.status === "skipped") {
|
|
9083
|
+
return { name, passed: true, blocking: false, skipped: true, message: `skipped \u2014 ${scan.reason}` };
|
|
9084
|
+
}
|
|
9085
|
+
if (scan.findings.length === 0) {
|
|
8753
9086
|
return {
|
|
8754
9087
|
name,
|
|
8755
|
-
passed:
|
|
9088
|
+
passed: true,
|
|
8756
9089
|
blocking: false,
|
|
8757
|
-
message:
|
|
8758
|
-
};
|
|
8759
|
-
}
|
|
8760
|
-
const repoExpectsAgents = await rosterExists(targetDir);
|
|
8761
|
-
if (repoExpectsAgents && !await rosterExists(storeRoot, "user")) {
|
|
8762
|
-
return {
|
|
8763
|
-
name,
|
|
8764
|
-
passed: false,
|
|
8765
|
-
message: `this repository has an agent roster and takes its client files from the user tier, but ${storeRoot} has no roster of its own \u2014 no agent mode can reach a client here. Run \`spell agents init --user\`, or set spell_scope back to "repo" and run \`spell agents sync\`.`
|
|
9090
|
+
message: `${scan.activeDocs.length} active doc(s) scanned, no unknown placeholders`
|
|
8766
9091
|
};
|
|
8767
9092
|
}
|
|
9093
|
+
const listed = scan.findings.map(({ file, tokens }) => `.arcane/governance/${file}: ${tokens.join(", ")}`);
|
|
8768
9094
|
return {
|
|
8769
9095
|
name,
|
|
8770
|
-
passed:
|
|
9096
|
+
passed: false,
|
|
8771
9097
|
blocking: false,
|
|
8772
|
-
message: `
|
|
9098
|
+
message: `unknown placeholder(s) in status: active doc(s) \u2014 ${listed.join("; ")}. Fill each one in; or, if an agent resolves it at use time, add it to the runtime-placeholders list in .arcane/governance/${PLACEHOLDER_STANDARD_FILE}; or, if the document is meant to be filled in by hand, mark it \`status: template\`.`
|
|
8773
9099
|
};
|
|
8774
9100
|
}
|
|
8775
|
-
|
|
8776
|
-
|
|
8777
|
-
|
|
8778
|
-
|
|
8779
|
-
|
|
8780
|
-
|
|
8781
|
-
|
|
8782
|
-
|
|
8783
|
-
|
|
8784
|
-
process.exitCode = 1;
|
|
8785
|
-
} else {
|
|
8786
|
-
console.log("\n All checks passed. Your environment is ready.\n");
|
|
8787
|
-
}
|
|
8788
|
-
return;
|
|
9101
|
+
var UNOWNED_EXAMPLE_SCOPE = "@example/";
|
|
9102
|
+
var RETIRED_SCAFFOLD_PLACEHOLDER = "<your-mcp-server-package>";
|
|
9103
|
+
async function checkComponentRequires(targetDir) {
|
|
9104
|
+
const name = "Component prerequisites";
|
|
9105
|
+
let manifest;
|
|
9106
|
+
try {
|
|
9107
|
+
manifest = await readManifest(targetDir);
|
|
9108
|
+
} catch {
|
|
9109
|
+
return { name, passed: true, blocking: false, skipped: true, message: "skipped \u2014 no readable .arcane.json" };
|
|
8789
9110
|
}
|
|
8790
|
-
|
|
8791
|
-
|
|
8792
|
-
checkVSCodeExtension("GitHub.copilot-chat", "GitHub Copilot (Chat)", resolveHomeDir()),
|
|
8793
|
-
checkArcaneManifest(targetDir),
|
|
8794
|
-
Promise.resolve(checkIncidentReleaseGate()),
|
|
8795
|
-
checkPullRebase(targetDir),
|
|
8796
|
-
checkPushPolicy(targetDir),
|
|
8797
|
-
checkPlatformBranchPolicy(targetDir),
|
|
8798
|
-
checkDelegations(targetDir),
|
|
8799
|
-
checkMcpConfig(targetDir),
|
|
8800
|
-
checkUserTier(resolveHomeDir(), options.packageVersion),
|
|
8801
|
-
checkSpellScope(targetDir),
|
|
8802
|
-
checkManagedFileModes(targetDir),
|
|
8803
|
-
checkGovernancePlaceholders(targetDir),
|
|
8804
|
-
checkComponentRequires(targetDir),
|
|
8805
|
-
checkNewlyAvailableComponents(targetDir)
|
|
8806
|
-
]);
|
|
8807
|
-
const sessionResults = await checkSessionContinuity(targetDir);
|
|
8808
|
-
results.push(...sessionResults);
|
|
8809
|
-
let allPassed = true;
|
|
8810
|
-
for (const result of results) {
|
|
8811
|
-
const skipped = result.passed && result.skipped === true;
|
|
8812
|
-
const icon = skipped ? "\u2013" : result.passed ? "\u2713" : result.blocking === false ? "\u26A0" : "\u2717";
|
|
8813
|
-
const label2 = skipped ? "skip" : result.passed ? "pass" : result.blocking === false ? "warn" : "FAIL";
|
|
8814
|
-
console.log(` ${icon} [${label2}] ${result.name}`);
|
|
8815
|
-
if (!result.passed || skipped) {
|
|
8816
|
-
console.log(` ${result.message}`);
|
|
8817
|
-
}
|
|
8818
|
-
if (!result.passed && result.blocking !== false) {
|
|
8819
|
-
allPassed = false;
|
|
8820
|
-
}
|
|
9111
|
+
if (manifest.scope === "user") {
|
|
9112
|
+
return { name, passed: true, blocking: false, skipped: true, message: "skipped \u2014 the user tier holds spells only" };
|
|
8821
9113
|
}
|
|
8822
|
-
|
|
8823
|
-
|
|
8824
|
-
console.log(" All checks passed. Your environment is ready.\n");
|
|
8825
|
-
} else {
|
|
8826
|
-
console.log(" One or more checks failed. Fix the issues above before proceeding.\n");
|
|
8827
|
-
process.exitCode = 1;
|
|
9114
|
+
if (!Array.isArray(manifest.components)) {
|
|
9115
|
+
return { name, passed: true, blocking: false, skipped: true, message: "skipped \u2014 .arcane.json lists no components" };
|
|
8828
9116
|
}
|
|
8829
|
-
const
|
|
8830
|
-
if (
|
|
8831
|
-
|
|
8832
|
-
const created = await fixSessionContinuity(targetDir, assetsDir);
|
|
8833
|
-
for (const file of created) {
|
|
8834
|
-
console.log(` \u2713 Created: ${file}`);
|
|
8835
|
-
}
|
|
8836
|
-
const stillMissing = (await checkSessionContinuity(targetDir)).filter((r) => !r.passed).length;
|
|
8837
|
-
if (stillMissing > 0) {
|
|
8838
|
-
console.log(
|
|
8839
|
-
`
|
|
8840
|
-
${created.length} file(s) created; ${stillMissing} still missing (named above). Repo is not close-session-ready yet.
|
|
8841
|
-
`
|
|
8842
|
-
);
|
|
8843
|
-
} else if (created.length > 0) {
|
|
8844
|
-
console.log(`
|
|
8845
|
-
${created.length} file(s) created. Repo is now close-session-ready.
|
|
8846
|
-
`);
|
|
8847
|
-
} else {
|
|
8848
|
-
console.log(" No files needed creation (all present).\n");
|
|
8849
|
-
}
|
|
8850
|
-
} else if (hasMissingSession && !options.fix) {
|
|
8851
|
-
console.log(" \u{1F4A1} Run `spell doctor --fix` to create missing session files.\n");
|
|
9117
|
+
const missing = findMissingRequires(manifest.components, manifest.profile);
|
|
9118
|
+
if (missing.length === 0) {
|
|
9119
|
+
return { name, passed: true, blocking: false, message: "every installed component's prerequisites are installed" };
|
|
8852
9120
|
}
|
|
9121
|
+
return {
|
|
9122
|
+
name,
|
|
9123
|
+
passed: false,
|
|
9124
|
+
blocking: false,
|
|
9125
|
+
message: missing.map(({ name: required, requiredBy }) => `${requiredBy.join(", ")} cites ${required}, which is not installed \u2014 ${getComponent(required).initOnly ? "" : "`spell update` installs it, or "}\`spell add ${required}\``).join("; ")
|
|
9126
|
+
};
|
|
8853
9127
|
}
|
|
8854
|
-
|
|
8855
|
-
|
|
8856
|
-
|
|
8857
|
-
|
|
8858
|
-
// src/modules/ward.ts
|
|
8859
|
-
import { readdir as readdir12, readFile as readFile20 } from "fs/promises";
|
|
8860
|
-
import { join as join23, relative as relative2 } from "path";
|
|
8861
|
-
async function scanFileAllRules(absolutePath, displayPath, rules) {
|
|
8862
|
-
const findings = [];
|
|
8863
|
-
let content;
|
|
9128
|
+
async function checkNewlyAvailableComponents(targetDir) {
|
|
9129
|
+
const name = "Newly available components";
|
|
9130
|
+
let manifest;
|
|
8864
9131
|
try {
|
|
8865
|
-
|
|
9132
|
+
manifest = await readManifest(targetDir);
|
|
8866
9133
|
} catch {
|
|
8867
|
-
return
|
|
9134
|
+
return { name, passed: true, blocking: false, skipped: true, message: "skipped \u2014 no readable .arcane.json" };
|
|
8868
9135
|
}
|
|
8869
|
-
|
|
8870
|
-
|
|
8871
|
-
const searchable = lines[index].replace(/\{[A-Z][A-Z0-9_]*\}/g, "");
|
|
8872
|
-
for (const rule of rules) {
|
|
8873
|
-
if (rule.pattern.test(searchable)) {
|
|
8874
|
-
findings.push({ file: displayPath, line: index + 1, rule: rule.label });
|
|
8875
|
-
}
|
|
8876
|
-
}
|
|
9136
|
+
if (manifest.scope === "user" || !Array.isArray(manifest.components)) {
|
|
9137
|
+
return { name, passed: true, blocking: false, skipped: true, message: "skipped \u2014 no repository install to compare" };
|
|
8877
9138
|
}
|
|
8878
|
-
|
|
8879
|
-
}
|
|
8880
|
-
|
|
8881
|
-
|
|
8882
|
-
|
|
8883
|
-
|
|
8884
|
-
|
|
8885
|
-
|
|
8886
|
-
|
|
8887
|
-
|
|
8888
|
-
|
|
8889
|
-
|
|
8890
|
-
|
|
8891
|
-
|
|
8892
|
-
|
|
8893
|
-
|
|
8894
|
-
|
|
8895
|
-
|
|
8896
|
-
".js",
|
|
8897
|
-
".jsx",
|
|
8898
|
-
".json",
|
|
8899
|
-
".yml",
|
|
8900
|
-
".yaml",
|
|
8901
|
-
".txt",
|
|
8902
|
-
".mdx"
|
|
8903
|
-
];
|
|
8904
|
-
function applyExclusions(findings, fileLines, exclusions) {
|
|
8905
|
-
if (exclusions.length === 0) return findings;
|
|
8906
|
-
return findings.filter((finding) => {
|
|
8907
|
-
const applicable = exclusions.filter((e) => e.rule === finding.rule);
|
|
8908
|
-
if (applicable.length === 0) return true;
|
|
8909
|
-
const lines = fileLines.get(finding.file);
|
|
8910
|
-
const lineText = lines?.[finding.line - 1] ?? "";
|
|
8911
|
-
return !applicable.some((e) => lineText.includes(e.context));
|
|
8912
|
-
});
|
|
9139
|
+
const installed = manifest.components.flatMap(
|
|
9140
|
+
(c) => (LEGACY_COMPONENT_MIGRATIONS[c.name] ?? [c.name]).map((n) => ({ ...c, name: n }))
|
|
9141
|
+
);
|
|
9142
|
+
const prerequisites = findMissingRequires(manifest.components, manifest.profile).map((m) => m.name);
|
|
9143
|
+
const fresh = (await findNewlyAvailable(targetDir, manifest.profile, installed, effectiveSpellScope(manifest))).filter((c) => !prerequisites.includes(c.name));
|
|
9144
|
+
if (fresh.length === 0) {
|
|
9145
|
+
return { name, passed: true, blocking: false, message: "the profile has nothing this install lacks" };
|
|
9146
|
+
}
|
|
9147
|
+
const offerable = fresh.filter((c) => !getComponent(c.name).initOnly).map((c) => c.name);
|
|
9148
|
+
const byHand = fresh.filter((c) => getComponent(c.name).initOnly).map((c) => c.name);
|
|
9149
|
+
const parts = [];
|
|
9150
|
+
if (offerable.length > 0) {
|
|
9151
|
+
parts.push(`${offerable.join(", ")} \u2014 \`spell update --add-new\` installs ${offerable.length === 1 ? "it" : "them"}`);
|
|
9152
|
+
}
|
|
9153
|
+
if (byHand.length > 0) {
|
|
9154
|
+
parts.push(`${byHand.join(", ")} \u2014 initOnly, so only ${byHand.map((n) => `\`spell add ${n}\``).join(", ")}`);
|
|
9155
|
+
}
|
|
9156
|
+
return { name, passed: false, blocking: false, message: `newly available for your profile, not installed: ${parts.join("; ")}` };
|
|
8913
9157
|
}
|
|
8914
|
-
async function
|
|
8915
|
-
const
|
|
8916
|
-
|
|
9158
|
+
async function checkMcpConfig(targetDir) {
|
|
9159
|
+
const name = "MCP config (per-server timeout, I12)";
|
|
9160
|
+
const filePath = join24(targetDir, ".mcp.json");
|
|
9161
|
+
let raw;
|
|
8917
9162
|
try {
|
|
8918
|
-
|
|
9163
|
+
raw = await readFile19(filePath, "utf8");
|
|
8919
9164
|
} catch {
|
|
8920
|
-
return
|
|
9165
|
+
return { name, passed: true, blocking: false, message: "no .mcp.json \u2014 nothing to check" };
|
|
8921
9166
|
}
|
|
8922
|
-
|
|
8923
|
-
|
|
8924
|
-
|
|
8925
|
-
|
|
8926
|
-
|
|
8927
|
-
|
|
8928
|
-
|
|
8929
|
-
|
|
8930
|
-
|
|
8931
|
-
|
|
8932
|
-
findings.push({ file: displayPath, line: 0, rule: rule.label });
|
|
8933
|
-
break;
|
|
8934
|
-
}
|
|
8935
|
-
}
|
|
9167
|
+
let rawParsed;
|
|
9168
|
+
try {
|
|
9169
|
+
rawParsed = JSON.parse(raw);
|
|
9170
|
+
} catch {
|
|
9171
|
+
return {
|
|
9172
|
+
name,
|
|
9173
|
+
passed: false,
|
|
9174
|
+
blocking: false,
|
|
9175
|
+
message: ".mcp.json exists but contains invalid JSON"
|
|
9176
|
+
};
|
|
8936
9177
|
}
|
|
8937
|
-
|
|
8938
|
-
|
|
8939
|
-
|
|
8940
|
-
|
|
8941
|
-
|
|
8942
|
-
|
|
8943
|
-
|
|
8944
|
-
}));
|
|
8945
|
-
}
|
|
8946
|
-
async function runWard(repoRoot, options = {}) {
|
|
8947
|
-
const userTerms = options.terms ?? [];
|
|
8948
|
-
const exclusions = options.exclusions ?? [];
|
|
8949
|
-
const extensions = [...WARD_TEXT_EXTENSIONS, ...options.binaryExtensions ?? []];
|
|
8950
|
-
const userRules = createDenylistRules(userTerms, "ward-term");
|
|
8951
|
-
const vendorRules = createDenylistRules([...VENDOR_IDENTIFIER_PROTECTION_LIST], "ward-vendor");
|
|
8952
|
-
const allRules = [...userRules, ...vendorRules];
|
|
8953
|
-
const contentFiles = await collectScannableFiles(repoRoot, repoRoot, extensions);
|
|
8954
|
-
const fileLines = /* @__PURE__ */ new Map();
|
|
8955
|
-
const contentFindings = [];
|
|
8956
|
-
for (const [absolutePath, displayPath] of contentFiles) {
|
|
8957
|
-
const findings2 = await scanFileAllRules(absolutePath, displayPath, allRules);
|
|
8958
|
-
if (findings2.length > 0) {
|
|
8959
|
-
try {
|
|
8960
|
-
fileLines.set(displayPath, (await readFile20(absolutePath, "utf8")).split("\n"));
|
|
8961
|
-
} catch {
|
|
8962
|
-
}
|
|
8963
|
-
}
|
|
8964
|
-
contentFindings.push(...findings2);
|
|
9178
|
+
if (!isRecord7(rawParsed)) {
|
|
9179
|
+
return {
|
|
9180
|
+
name,
|
|
9181
|
+
passed: false,
|
|
9182
|
+
blocking: false,
|
|
9183
|
+
message: ".mcp.json exists but is not a JSON object"
|
|
9184
|
+
};
|
|
8965
9185
|
}
|
|
8966
|
-
const
|
|
8967
|
-
const
|
|
8968
|
-
const
|
|
8969
|
-
|
|
8970
|
-
|
|
8971
|
-
|
|
8972
|
-
|
|
8973
|
-
|
|
8974
|
-
const
|
|
8975
|
-
|
|
9186
|
+
const parsed = rawParsed;
|
|
9187
|
+
const servers = parsed.mcpServers ?? {};
|
|
9188
|
+
const serverNames = Object.keys(servers);
|
|
9189
|
+
if (serverNames.length === 0) {
|
|
9190
|
+
return { name, passed: true, blocking: false, message: "no MCP servers configured" };
|
|
9191
|
+
}
|
|
9192
|
+
const exampleHits = [];
|
|
9193
|
+
const placeholderHits = [];
|
|
9194
|
+
for (const n of serverNames) {
|
|
9195
|
+
const args = servers[n]?.args;
|
|
9196
|
+
if (!Array.isArray(args)) continue;
|
|
9197
|
+
for (const a of args) {
|
|
9198
|
+
if (typeof a !== "string") continue;
|
|
9199
|
+
if (a.startsWith(UNOWNED_EXAMPLE_SCOPE)) exampleHits.push(`${n} (${a})`);
|
|
9200
|
+
if (a === RETIRED_SCAFFOLD_PLACEHOLDER) placeholderHits.push(n);
|
|
9201
|
+
}
|
|
9202
|
+
}
|
|
9203
|
+
if (exampleHits.length > 0) {
|
|
9204
|
+
return {
|
|
9205
|
+
name,
|
|
9206
|
+
passed: false,
|
|
9207
|
+
blocking: false,
|
|
9208
|
+
message: `.mcp.json runs a package under the ${UNOWNED_EXAMPLE_SCOPE} scope nobody here owns: ${exampleHits.join(", ")}. The old \`spell init\` scaffold shipped it as a placeholder, and \`npx -y\` would run whoever publishes it. Replace the entry with your own MCP server package, or remove it; the file is yours, so doctor does not edit it`
|
|
9209
|
+
};
|
|
9210
|
+
}
|
|
9211
|
+
if (placeholderHits.length > 0) {
|
|
9212
|
+
return {
|
|
9213
|
+
name,
|
|
9214
|
+
passed: false,
|
|
9215
|
+
blocking: false,
|
|
9216
|
+
message: `.mcp.json still holds the placeholder server from the retired mcp-config-template scaffold: ${placeholderHits.join(", ")} (${RETIRED_SCAFFOLD_PLACEHOLDER}). Clients load this file as live MCP config, so the placeholder fails on every session start. Delete the file if you run no MCP servers, or replace the entry with a real server package; the file is yours, so doctor does not edit it. An untouched scaffold is removed by \`spell update --prune\``
|
|
9217
|
+
};
|
|
9218
|
+
}
|
|
9219
|
+
const missingTimeout = serverNames.filter((n) => typeof servers[n]?.timeout !== "number");
|
|
9220
|
+
if (missingTimeout.length > 0) {
|
|
9221
|
+
return {
|
|
9222
|
+
name,
|
|
9223
|
+
passed: false,
|
|
9224
|
+
blocking: false,
|
|
9225
|
+
message: `server(s) missing a "timeout": ${missingTimeout.join(", ")} \u2014 add "timeout": 30000 (milliseconds; Claude Code ignores a value below 1000) to each entry under mcpServers, so a stuck tool call ends after 30 s instead of running to the client's own idle limit (as long as 30 minutes)`
|
|
9226
|
+
};
|
|
9227
|
+
}
|
|
9228
|
+
return {
|
|
9229
|
+
name,
|
|
9230
|
+
passed: true,
|
|
9231
|
+
blocking: false,
|
|
9232
|
+
message: `${serverNames.length} server(s) configured, all with a timeout set`
|
|
9233
|
+
};
|
|
8976
9234
|
}
|
|
8977
|
-
|
|
8978
|
-
|
|
8979
|
-
function parseTerms(raw) {
|
|
8980
|
-
if (!raw) return [];
|
|
8981
|
-
return raw.split(",").map((t) => t.trim()).filter(Boolean);
|
|
9235
|
+
function isDelegation(value) {
|
|
9236
|
+
return isRecord7(value) && typeof value["id"] === "string" && typeof value["status"] === "string" && typeof value["scope"] === "string" && Array.isArray(value["excludedActions"]);
|
|
8982
9237
|
}
|
|
8983
|
-
function
|
|
8984
|
-
return
|
|
9238
|
+
function isDelegationsFile(value) {
|
|
9239
|
+
return isRecord7(value) && Array.isArray(value["delegations"]) && value["delegations"].every(isDelegation);
|
|
8985
9240
|
}
|
|
8986
|
-
async function
|
|
9241
|
+
async function checkDelegations(targetDir) {
|
|
9242
|
+
const name = "Standing delegations (T13)";
|
|
9243
|
+
const filePath = join24(targetDir, ".arcane", "delegations.json");
|
|
9244
|
+
let raw;
|
|
8987
9245
|
try {
|
|
8988
|
-
|
|
8989
|
-
} catch
|
|
8990
|
-
|
|
8991
|
-
`\u26A0 arcane: could not read --terms-file "${filePath}": ${error2.message}`
|
|
8992
|
-
);
|
|
8993
|
-
return [];
|
|
9246
|
+
raw = await readFile19(filePath, "utf8");
|
|
9247
|
+
} catch {
|
|
9248
|
+
return { name, passed: true, blocking: false, message: "no delegations recorded" };
|
|
8994
9249
|
}
|
|
8995
|
-
|
|
8996
|
-
|
|
8997
|
-
|
|
8998
|
-
|
|
8999
|
-
|
|
9000
|
-
|
|
9001
|
-
|
|
9002
|
-
|
|
9003
|
-
|
|
9004
|
-
|
|
9005
|
-
if (leaks.length === 0 && protectedHits.length === 0) {
|
|
9006
|
-
console.log(" \u2713 No denylisted terms found.");
|
|
9250
|
+
let rawParsed;
|
|
9251
|
+
try {
|
|
9252
|
+
rawParsed = JSON.parse(raw);
|
|
9253
|
+
} catch {
|
|
9254
|
+
return {
|
|
9255
|
+
name,
|
|
9256
|
+
passed: false,
|
|
9257
|
+
blocking: false,
|
|
9258
|
+
message: `.arcane/delegations.json exists but contains invalid JSON`
|
|
9259
|
+
};
|
|
9007
9260
|
}
|
|
9008
|
-
|
|
9009
|
-
|
|
9010
|
-
|
|
9261
|
+
if (!isDelegationsFile(rawParsed)) {
|
|
9262
|
+
return {
|
|
9263
|
+
name,
|
|
9264
|
+
passed: false,
|
|
9265
|
+
blocking: false,
|
|
9266
|
+
message: `.arcane/delegations.json exists but is not a valid delegations file (expected a "delegations" array of well-formed entries)`
|
|
9267
|
+
};
|
|
9011
9268
|
}
|
|
9012
|
-
|
|
9013
|
-
|
|
9014
|
-
|
|
9015
|
-
|
|
9016
|
-
);
|
|
9269
|
+
const parsed = rawParsed;
|
|
9270
|
+
const active = parsed.delegations.filter((d) => d.status === "active");
|
|
9271
|
+
if (active.length === 0) {
|
|
9272
|
+
return { name, passed: true, blocking: false, message: "no active delegations" };
|
|
9017
9273
|
}
|
|
9018
|
-
|
|
9019
|
-
|
|
9020
|
-
|
|
9021
|
-
|
|
9022
|
-
|
|
9274
|
+
const summary = active.map((d) => `${d.id} (scope: ${d.scope}; excludes: ${d.excludedActions.length} action(s))`).join("; ");
|
|
9275
|
+
return {
|
|
9276
|
+
name,
|
|
9277
|
+
passed: true,
|
|
9278
|
+
blocking: false,
|
|
9279
|
+
message: `${active.length} active: ${summary}`
|
|
9280
|
+
};
|
|
9281
|
+
}
|
|
9282
|
+
async function checkPlatformBranchPolicy(targetDir) {
|
|
9283
|
+
const name = "Platform branch/merge policy (T11)";
|
|
9284
|
+
const targets = await pushTargets(targetDir);
|
|
9285
|
+
const origin = targets.find((t) => t.remote === "origin") ?? targets[0];
|
|
9286
|
+
const url = origin?.urls[0];
|
|
9287
|
+
if (!url) {
|
|
9288
|
+
return { name, passed: true, message: "no remote configured \u2014 nothing to check", blocking: false };
|
|
9023
9289
|
}
|
|
9024
|
-
|
|
9025
|
-
if (
|
|
9026
|
-
|
|
9027
|
-
|
|
9028
|
-
|
|
9029
|
-
|
|
9290
|
+
const github = parseGitHubRemote(url);
|
|
9291
|
+
if (github) {
|
|
9292
|
+
const rulesets = await fetchGitHubRulesets(github.owner, github.repo);
|
|
9293
|
+
if (rulesets === null) {
|
|
9294
|
+
return {
|
|
9295
|
+
name,
|
|
9296
|
+
passed: false,
|
|
9297
|
+
blocking: false,
|
|
9298
|
+
message: `could not query GitHub Rulesets for ${github.owner}/${github.repo} \u2014 is \`gh\` installed and authenticated? Verify manually: gh api repos/${github.owner}/${github.repo}/rulesets`
|
|
9299
|
+
};
|
|
9300
|
+
}
|
|
9301
|
+
const result = evaluateGitHubMergePolicy(rulesets);
|
|
9302
|
+
return { name, passed: result.passed, blocking: false, message: result.message };
|
|
9030
9303
|
}
|
|
9031
|
-
|
|
9032
|
-
|
|
9033
|
-
|
|
9304
|
+
const ado = parseAdoRemote2(url);
|
|
9305
|
+
if (ado) {
|
|
9306
|
+
const policies = await fetchAdoMergeTypePolicies(ado.org, ado.project, ado.repo, "main");
|
|
9307
|
+
if (policies === null) {
|
|
9308
|
+
return {
|
|
9309
|
+
name,
|
|
9310
|
+
passed: false,
|
|
9311
|
+
blocking: false,
|
|
9312
|
+
message: `could not query the Azure DevOps "Limit merge types" branch policy for ${ado.org}/${ado.project}/${ado.repo} \u2014 is \`az\` installed and authenticated? Verify manually in the ADO web UI under Branch Policies.`
|
|
9313
|
+
};
|
|
9314
|
+
}
|
|
9315
|
+
const result = evaluateAdoMergePolicy(policies);
|
|
9316
|
+
return { name, passed: result.passed, blocking: false, message: result.message };
|
|
9034
9317
|
}
|
|
9318
|
+
return {
|
|
9319
|
+
name,
|
|
9320
|
+
passed: true,
|
|
9321
|
+
blocking: false,
|
|
9322
|
+
message: "remote is neither GitHub nor Azure DevOps \u2014 platform policy verification not available for this provider"
|
|
9323
|
+
};
|
|
9035
9324
|
}
|
|
9036
|
-
|
|
9037
|
-
|
|
9038
|
-
import { basename as basename3, resolve as resolve8 } from "path";
|
|
9039
|
-
import { input as input4 } from "@inquirer/prompts";
|
|
9040
|
-
async function runUnblockPush(targetDir) {
|
|
9325
|
+
async function checkManagedFileModes(targetDir) {
|
|
9326
|
+
const name = "Managed files are regular files (#357)";
|
|
9041
9327
|
let manifest;
|
|
9042
9328
|
try {
|
|
9043
9329
|
manifest = await readManifest(targetDir);
|
|
9044
|
-
} catch
|
|
9045
|
-
|
|
9046
|
-
console.error('Not initialized. Run "spell init" first.');
|
|
9047
|
-
process.exit(1);
|
|
9048
|
-
return;
|
|
9049
|
-
}
|
|
9050
|
-
throw err;
|
|
9330
|
+
} catch {
|
|
9331
|
+
return { name, passed: true, skipped: true, message: "no .arcane.json \u2014 nothing to check" };
|
|
9051
9332
|
}
|
|
9052
|
-
|
|
9053
|
-
|
|
9054
|
-
|
|
9055
|
-
|
|
9056
|
-
return;
|
|
9333
|
+
try {
|
|
9334
|
+
await runGit(targetDir, ["rev-parse", "--git-dir"]);
|
|
9335
|
+
} catch {
|
|
9336
|
+
return { name, passed: true, skipped: true, message: "not a git repository \u2014 the index cannot be read" };
|
|
9057
9337
|
}
|
|
9058
|
-
|
|
9059
|
-
|
|
9060
|
-
|
|
9061
|
-
|
|
9338
|
+
const managed = [...new Set(manifest.components.flatMap((c) => c.files))];
|
|
9339
|
+
const linkTracked = await listSymlinkTrackedPaths(targetDir);
|
|
9340
|
+
const asLinks = managed.filter((file) => linkTracked.has(file));
|
|
9341
|
+
const pathBodies = [];
|
|
9342
|
+
for (const file of managed) {
|
|
9343
|
+
if (!isClientShimPath(file) || asLinks.includes(file)) continue;
|
|
9344
|
+
let body;
|
|
9345
|
+
try {
|
|
9346
|
+
body = await readFile19(join24(targetDir, file), "utf-8");
|
|
9347
|
+
} catch {
|
|
9348
|
+
continue;
|
|
9349
|
+
}
|
|
9350
|
+
if (looksLikeBarePath(body)) pathBodies.push(file);
|
|
9062
9351
|
}
|
|
9063
|
-
if (
|
|
9064
|
-
|
|
9065
|
-
"spell unblock-push must be run from an interactive terminal.\nLifting a push block is a deliberate decision, so it cannot be scripted, piped, or run in CI."
|
|
9066
|
-
);
|
|
9067
|
-
process.exit(1);
|
|
9068
|
-
return;
|
|
9352
|
+
if (asLinks.length === 0 && pathBodies.length === 0) {
|
|
9353
|
+
return { name, passed: true, message: `${managed.length} managed path(s), all regular files` };
|
|
9069
9354
|
}
|
|
9070
|
-
const
|
|
9071
|
-
if (
|
|
9072
|
-
|
|
9073
|
-
"Could not determine a repository name to confirm against (is this repository at a filesystem root?). Refusing to unblock."
|
|
9074
|
-
);
|
|
9075
|
-
process.exit(1);
|
|
9076
|
-
return;
|
|
9355
|
+
const parts = [];
|
|
9356
|
+
if (asLinks.length > 0) {
|
|
9357
|
+
parts.push(`${asLinks.length} tracked as a symlink (mode 120000): ${asLinks.join(", ")}`);
|
|
9077
9358
|
}
|
|
9078
|
-
|
|
9079
|
-
|
|
9080
|
-
"Unblocking restores the ability to push this repository's full history to its remote. Deleting a file later does not undo a push: the content stays in history, in any clone already made, and in the remote's own backups."
|
|
9081
|
-
);
|
|
9082
|
-
console.log();
|
|
9083
|
-
const typed = await input4({
|
|
9084
|
-
message: `Type the repository name to confirm (${repoName}):`
|
|
9085
|
-
});
|
|
9086
|
-
if (typed.trim() !== repoName) {
|
|
9087
|
-
console.error(`
|
|
9088
|
-
Name did not match ("${typed.trim()}" \u2260 "${repoName}"). Nothing changed.`);
|
|
9089
|
-
process.exit(1);
|
|
9090
|
-
return;
|
|
9359
|
+
if (pathBodies.length > 0) {
|
|
9360
|
+
parts.push(`${pathBodies.length} client shim(s) whose body is only a path: ${pathBodies.join(", ")}`);
|
|
9091
9361
|
}
|
|
9092
|
-
|
|
9093
|
-
|
|
9094
|
-
|
|
9095
|
-
|
|
9096
|
-
const stillBlocked = await blockedRemotes(targetDir);
|
|
9097
|
-
const updated = {
|
|
9098
|
-
...manifest,
|
|
9099
|
-
push_policy: "open",
|
|
9100
|
-
push_policy_unblocked_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
9362
|
+
return {
|
|
9363
|
+
name,
|
|
9364
|
+
passed: false,
|
|
9365
|
+
message: parts.join("; ") + " \u2014 clients silently skip or empty these spells. Run `spell update` (it rewrites them as regular files and stages the typechange), then commit."
|
|
9101
9366
|
};
|
|
9102
|
-
await writeManifest(targetDir, updated);
|
|
9103
|
-
const hookStillOn = await isHookEnforced(targetDir);
|
|
9104
|
-
const fullyLifted = failed.length === 0 && stillBlocked.length === 0 && !hookStillOn;
|
|
9105
|
-
if (fullyLifted) {
|
|
9106
|
-
printSuccess(`Push unblocked for "${repoName}".`);
|
|
9107
|
-
} else {
|
|
9108
|
-
printWarning(
|
|
9109
|
-
`Push only PARTIALLY unblocked for "${repoName}". The manifest now says open; the repository is not fully open yet. Details below.`
|
|
9110
|
-
);
|
|
9111
|
-
}
|
|
9112
|
-
if (restored.length > 0) {
|
|
9113
|
-
printInfo(`Restored push URL for: ${restored.join(", ")}.`);
|
|
9114
|
-
}
|
|
9115
|
-
if (hookStillOn) {
|
|
9116
|
-
printWarning(
|
|
9117
|
-
"The pre-push hook is still in force. Arcane did not remove it because `core.hooksPath` is not one it owns, could not be read, or names a directory that a link takes outside this repository. Check `git config --show-scope --get core.hooksPath`."
|
|
9118
|
-
);
|
|
9119
|
-
}
|
|
9120
|
-
if (failed.length > 0) {
|
|
9121
|
-
printWarning(
|
|
9122
|
-
`Could not restore the push URL for: ${failed.map((r) => `${r.remote} (${r.reason ?? "unknown error"})`).join("; ")}.`
|
|
9123
|
-
);
|
|
9124
|
-
}
|
|
9125
|
-
if (stillBlocked.length > 0) {
|
|
9126
|
-
printWarning(
|
|
9127
|
-
`These remotes still have a blocked push URL and no recorded original: ${stillBlocked.join(", ")}. Set them yourself with \`git remote set-url --push <name> <url>\` \u2014 until then, pushing to them will fail even though the manifest now says open.`
|
|
9128
|
-
);
|
|
9129
|
-
}
|
|
9130
|
-
printInfo(
|
|
9131
|
-
"Recorded in .arcane.json as push_policy: open, with the time it was lifted. Commit that change so the repository's actual posture stays visible to everyone."
|
|
9132
|
-
);
|
|
9133
9367
|
}
|
|
9134
|
-
|
|
9135
|
-
|
|
9136
|
-
async function runBlockPush(targetDir) {
|
|
9368
|
+
async function checkSpellScope(targetDir, homeDir = resolveHomeDir()) {
|
|
9369
|
+
const name = "Spell scope (ARC-045)";
|
|
9137
9370
|
let manifest;
|
|
9138
9371
|
try {
|
|
9139
9372
|
manifest = await readManifest(targetDir);
|
|
9373
|
+
} catch {
|
|
9374
|
+
return { name, passed: true, blocking: false, message: "no .arcane.json \u2014 nothing to check" };
|
|
9375
|
+
}
|
|
9376
|
+
if (effectiveSpellScope(manifest) === "repo") {
|
|
9377
|
+
return {
|
|
9378
|
+
name,
|
|
9379
|
+
passed: true,
|
|
9380
|
+
blocking: false,
|
|
9381
|
+
message: "repo \u2014 this repository carries its own spells (the default)"
|
|
9382
|
+
};
|
|
9383
|
+
}
|
|
9384
|
+
const storeRoot = userTierRoot(homeDir);
|
|
9385
|
+
let store;
|
|
9386
|
+
try {
|
|
9387
|
+
store = await readManifest(storeRoot);
|
|
9140
9388
|
} catch (err) {
|
|
9141
|
-
|
|
9142
|
-
|
|
9143
|
-
|
|
9144
|
-
|
|
9145
|
-
}
|
|
9146
|
-
throw err;
|
|
9389
|
+
return {
|
|
9390
|
+
name,
|
|
9391
|
+
passed: false,
|
|
9392
|
+
message: `this repository takes its spells from the user tier (spell_scope: "user"), but ${storeRoot} ${err instanceof ManifestNotFoundError ? "is not installed" : `could not be read (${err instanceof Error ? err.message : String(err)})`} \u2014 no client can reach a spell here. Run \`spell init --user\`, or set spell_scope back to "repo" and run \`spell update\`.`
|
|
9393
|
+
};
|
|
9147
9394
|
}
|
|
9148
|
-
|
|
9149
|
-
|
|
9150
|
-
|
|
9151
|
-
|
|
9152
|
-
|
|
9153
|
-
|
|
9395
|
+
let spellCount = 0;
|
|
9396
|
+
let probed = false;
|
|
9397
|
+
const probe2 = "spell-status.md";
|
|
9398
|
+
try {
|
|
9399
|
+
const entries = await readdir11(join24(storeRoot, USER_TIER_SPELLS_DIR));
|
|
9400
|
+
const spells = entries.filter((e) => e.startsWith("spell-") && e.endsWith(".md"));
|
|
9401
|
+
spellCount = spells.length;
|
|
9402
|
+
probed = spells.includes(probe2);
|
|
9403
|
+
} catch {
|
|
9154
9404
|
}
|
|
9155
|
-
if (
|
|
9156
|
-
|
|
9157
|
-
|
|
9158
|
-
|
|
9405
|
+
if (spellCount === 0 || !probed) {
|
|
9406
|
+
return {
|
|
9407
|
+
name,
|
|
9408
|
+
passed: false,
|
|
9409
|
+
message: `the user tier at ${storeRoot} holds ${spellCount} spell(s)${spellCount > 0 && !probed ? ` and not ${probe2}` : ""} \u2014 this repository expects it to supply every spell. Run \`spell update --user\`.`
|
|
9410
|
+
};
|
|
9159
9411
|
}
|
|
9160
|
-
const
|
|
9161
|
-
|
|
9162
|
-
|
|
9163
|
-
|
|
9164
|
-
|
|
9165
|
-
|
|
9166
|
-
|
|
9167
|
-
|
|
9412
|
+
const majorMinor = (v) => v.split(".").slice(0, 2).join(".");
|
|
9413
|
+
if (typeof manifest.version === "string" && majorMinor(store.version) !== majorMinor(manifest.version)) {
|
|
9414
|
+
return {
|
|
9415
|
+
name,
|
|
9416
|
+
passed: false,
|
|
9417
|
+
blocking: false,
|
|
9418
|
+
message: `this repository is at v${manifest.version} but the user tier it uses is at v${store.version} \u2014 run \`spell update --user\` (and \`spell update\` here) to bring them together.`
|
|
9419
|
+
};
|
|
9420
|
+
}
|
|
9421
|
+
const repoExpectsAgents = await rosterExists(targetDir);
|
|
9422
|
+
if (repoExpectsAgents && !await rosterExists(storeRoot, "user")) {
|
|
9423
|
+
return {
|
|
9424
|
+
name,
|
|
9425
|
+
passed: false,
|
|
9426
|
+
message: `this repository has an agent roster and takes its client files from the user tier, but ${storeRoot} has no roster of its own \u2014 no agent mode can reach a client here. Run \`spell agents init --user\`, or set spell_scope back to "repo" and run \`spell agents sync\`.`
|
|
9427
|
+
};
|
|
9428
|
+
}
|
|
9429
|
+
return {
|
|
9430
|
+
name,
|
|
9431
|
+
passed: true,
|
|
9432
|
+
blocking: false,
|
|
9433
|
+
message: `user \u2014 ${spellCount} spell(s) from ${storeRoot} (v${store.version}), ${probe2} present${repoExpectsAgents ? ", agent roster present in the tier" : ""}`
|
|
9434
|
+
};
|
|
9435
|
+
}
|
|
9436
|
+
async function runDoctor(targetDir, options = {}, assetsDir) {
|
|
9437
|
+
console.log("\nspell doctor \u2014 checking your Arcane environment\n");
|
|
9438
|
+
if (options.leaks) {
|
|
9439
|
+
const result = await checkSecrets(targetDir);
|
|
9440
|
+
const icon = result.passed ? "\u2713" : "\u2717";
|
|
9441
|
+
console.log(` ${icon} [${result.passed ? "pass" : "FAIL"}] ${result.name}`);
|
|
9442
|
+
if (!result.passed) {
|
|
9443
|
+
console.log(` ${result.message}`);
|
|
9444
|
+
console.log("\n One or more checks failed. Fix the issues above before proceeding.\n");
|
|
9445
|
+
process.exitCode = 1;
|
|
9446
|
+
} else {
|
|
9447
|
+
console.log("\n All checks passed. Your environment is ready.\n");
|
|
9168
9448
|
}
|
|
9169
|
-
printInfo("Run `spell unblock-push` from a terminal to undo this.");
|
|
9170
9449
|
return;
|
|
9171
9450
|
}
|
|
9172
|
-
const
|
|
9173
|
-
|
|
9174
|
-
|
|
9175
|
-
|
|
9176
|
-
|
|
9177
|
-
|
|
9178
|
-
|
|
9179
|
-
|
|
9180
|
-
)
|
|
9181
|
-
|
|
9182
|
-
|
|
9183
|
-
|
|
9184
|
-
|
|
9451
|
+
const results = await Promise.all([
|
|
9452
|
+
checkNodeVersion(),
|
|
9453
|
+
checkArcaneManifest(targetDir),
|
|
9454
|
+
Promise.resolve(checkIncidentReleaseGate()),
|
|
9455
|
+
checkPullRebase(targetDir),
|
|
9456
|
+
checkPushPolicy(targetDir),
|
|
9457
|
+
checkPlatformBranchPolicy(targetDir),
|
|
9458
|
+
checkDelegations(targetDir),
|
|
9459
|
+
checkMcpConfig(targetDir),
|
|
9460
|
+
checkUserTier(resolveHomeDir(), options.packageVersion),
|
|
9461
|
+
checkSpellScope(targetDir),
|
|
9462
|
+
checkManagedFileModes(targetDir),
|
|
9463
|
+
checkGovernancePlaceholders(targetDir),
|
|
9464
|
+
checkComponentRequires(targetDir),
|
|
9465
|
+
checkNewlyAvailableComponents(targetDir),
|
|
9466
|
+
checkPresenceService()
|
|
9467
|
+
]);
|
|
9468
|
+
const sessionResults = await checkSessionContinuity(targetDir);
|
|
9469
|
+
results.push(...sessionResults);
|
|
9470
|
+
let allPassed = true;
|
|
9471
|
+
for (const result of results) {
|
|
9472
|
+
const skipped = result.passed && result.skipped === true;
|
|
9473
|
+
const icon = skipped ? "\u2013" : result.passed ? "\u2713" : result.blocking === false ? "\u26A0" : "\u2717";
|
|
9474
|
+
const label2 = skipped ? "skip" : result.passed ? "pass" : result.blocking === false ? "warn" : "FAIL";
|
|
9475
|
+
console.log(` ${icon} [${label2}] ${result.name}`);
|
|
9476
|
+
if (!result.passed || skipped) {
|
|
9477
|
+
console.log(` ${result.message}`);
|
|
9478
|
+
}
|
|
9479
|
+
if (!result.passed && result.blocking !== false) {
|
|
9480
|
+
allPassed = false;
|
|
9185
9481
|
}
|
|
9186
|
-
process.exit(1);
|
|
9187
|
-
return;
|
|
9188
9482
|
}
|
|
9189
|
-
|
|
9190
|
-
|
|
9191
|
-
|
|
9192
|
-
printInfo("Commit that change so the repository's posture stays visible to everyone.");
|
|
9483
|
+
console.log();
|
|
9484
|
+
if (allPassed) {
|
|
9485
|
+
console.log(" All checks passed. Your environment is ready.\n");
|
|
9193
9486
|
} else {
|
|
9194
|
-
|
|
9487
|
+
console.log(" One or more checks failed. Fix the issues above before proceeding.\n");
|
|
9488
|
+
process.exitCode = 1;
|
|
9489
|
+
}
|
|
9490
|
+
const hasMissingSession = sessionResults.some((r) => !r.passed);
|
|
9491
|
+
if (options.fix && hasMissingSession && assetsDir) {
|
|
9492
|
+
console.log(" \u{1F527} Fixing missing session continuity files...\n");
|
|
9493
|
+
const created = await fixSessionContinuity(targetDir, assetsDir);
|
|
9494
|
+
for (const file of created) {
|
|
9495
|
+
console.log(` \u2713 Created: ${file}`);
|
|
9496
|
+
}
|
|
9497
|
+
const stillMissing = (await checkSessionContinuity(targetDir)).filter((r) => !r.passed).length;
|
|
9498
|
+
if (stillMissing > 0) {
|
|
9499
|
+
console.log(
|
|
9500
|
+
`
|
|
9501
|
+
${created.length} file(s) created; ${stillMissing} still missing (named above). Repo is not close-session-ready yet.
|
|
9502
|
+
`
|
|
9503
|
+
);
|
|
9504
|
+
} else if (created.length > 0) {
|
|
9505
|
+
console.log(`
|
|
9506
|
+
${created.length} file(s) created. Repo is now close-session-ready.
|
|
9507
|
+
`);
|
|
9508
|
+
} else {
|
|
9509
|
+
console.log(" No files needed creation (all present).\n");
|
|
9510
|
+
}
|
|
9511
|
+
} else if (hasMissingSession && !options.fix) {
|
|
9512
|
+
console.log(" \u{1F4A1} Run `spell doctor --fix` to create missing session files.\n");
|
|
9513
|
+
}
|
|
9514
|
+
if (options.fix) {
|
|
9515
|
+
const removed = fixPresenceService();
|
|
9516
|
+
if (removed !== null) console.log(` \u{1F527} Removed the stale presence record ${removed} (the token stays).
|
|
9517
|
+
`);
|
|
9195
9518
|
}
|
|
9196
9519
|
}
|
|
9197
9520
|
|
|
9198
|
-
// src/
|
|
9199
|
-
import {
|
|
9200
|
-
|
|
9201
|
-
|
|
9202
|
-
|
|
9203
|
-
|
|
9204
|
-
|
|
9205
|
-
|
|
9206
|
-
|
|
9207
|
-
|
|
9208
|
-
|
|
9209
|
-
|
|
9521
|
+
// src/commands/ward.ts
|
|
9522
|
+
import { readFile as readFile21 } from "fs/promises";
|
|
9523
|
+
|
|
9524
|
+
// src/modules/ward.ts
|
|
9525
|
+
import { readdir as readdir12, readFile as readFile20 } from "fs/promises";
|
|
9526
|
+
import { join as join25, relative as relative3 } from "path";
|
|
9527
|
+
async function scanFileAllRules(absolutePath, displayPath, rules) {
|
|
9528
|
+
const findings = [];
|
|
9529
|
+
let content;
|
|
9530
|
+
try {
|
|
9531
|
+
content = await readFile20(absolutePath, "utf8");
|
|
9532
|
+
} catch {
|
|
9533
|
+
return findings;
|
|
9210
9534
|
}
|
|
9211
|
-
|
|
9212
|
-
|
|
9213
|
-
|
|
9214
|
-
|
|
9215
|
-
|
|
9216
|
-
|
|
9535
|
+
const lines = content.split("\n");
|
|
9536
|
+
for (let index = 0; index < lines.length; index++) {
|
|
9537
|
+
const searchable = lines[index].replace(/\{[A-Z][A-Z0-9_]*\}/g, "");
|
|
9538
|
+
for (const rule of rules) {
|
|
9539
|
+
if (rule.pattern.test(searchable)) {
|
|
9540
|
+
findings.push({ file: displayPath, line: index + 1, rule: rule.label });
|
|
9541
|
+
}
|
|
9542
|
+
}
|
|
9543
|
+
}
|
|
9544
|
+
return findings;
|
|
9217
9545
|
}
|
|
9218
|
-
|
|
9219
|
-
|
|
9220
|
-
|
|
9221
|
-
|
|
9222
|
-
|
|
9223
|
-
|
|
9224
|
-
|
|
9225
|
-
|
|
9226
|
-
|
|
9227
|
-
|
|
9228
|
-
|
|
9229
|
-
|
|
9230
|
-
|
|
9546
|
+
var VENDOR_IDENTIFIER_PROTECTION_LIST = [
|
|
9547
|
+
"claude",
|
|
9548
|
+
"gpt-4",
|
|
9549
|
+
"gpt-3.5",
|
|
9550
|
+
"gemini",
|
|
9551
|
+
"llama",
|
|
9552
|
+
"mistral",
|
|
9553
|
+
"copilot",
|
|
9554
|
+
"anthropic",
|
|
9555
|
+
"openai"
|
|
9556
|
+
];
|
|
9557
|
+
var GREP_PROOF_EXTENSIONS = [".gif", ".png", ".jpg", ".jpeg", ".mp4", ".mov", ".mp3", ".wav"];
|
|
9558
|
+
var WARD_TEXT_EXTENSIONS = [
|
|
9559
|
+
".md",
|
|
9560
|
+
".ts",
|
|
9561
|
+
".tsx",
|
|
9562
|
+
".js",
|
|
9563
|
+
".jsx",
|
|
9564
|
+
".json",
|
|
9565
|
+
".yml",
|
|
9566
|
+
".yaml",
|
|
9567
|
+
".txt",
|
|
9568
|
+
".mdx"
|
|
9569
|
+
];
|
|
9570
|
+
function applyExclusions(findings, fileLines, exclusions) {
|
|
9571
|
+
if (exclusions.length === 0) return findings;
|
|
9572
|
+
return findings.filter((finding) => {
|
|
9573
|
+
const applicable = exclusions.filter((e) => e.rule === finding.rule);
|
|
9574
|
+
if (applicable.length === 0) return true;
|
|
9575
|
+
const lines = fileLines.get(finding.file);
|
|
9576
|
+
const lineText = lines?.[finding.line - 1] ?? "";
|
|
9577
|
+
return !applicable.some((e) => lineText.includes(e.context));
|
|
9578
|
+
});
|
|
9231
9579
|
}
|
|
9232
|
-
function
|
|
9233
|
-
|
|
9580
|
+
async function scanFilenames(root, rules, repoRoot = root) {
|
|
9581
|
+
const findings = [];
|
|
9582
|
+
let entries;
|
|
9234
9583
|
try {
|
|
9235
|
-
|
|
9584
|
+
entries = await readdir12(root, { withFileTypes: true });
|
|
9236
9585
|
} catch {
|
|
9237
|
-
return
|
|
9586
|
+
return findings;
|
|
9238
9587
|
}
|
|
9239
|
-
|
|
9240
|
-
|
|
9241
|
-
|
|
9242
|
-
|
|
9588
|
+
for (const entry of entries) {
|
|
9589
|
+
const absolutePath = join25(root, entry.name);
|
|
9590
|
+
if (entry.isDirectory()) {
|
|
9591
|
+
if (SKIP_DIRECTORIES.has(entry.name)) continue;
|
|
9592
|
+
findings.push(...await scanFilenames(absolutePath, rules, repoRoot));
|
|
9593
|
+
continue;
|
|
9594
|
+
}
|
|
9595
|
+
const displayPath = relative3(repoRoot, absolutePath).replace(/\\/g, "/");
|
|
9596
|
+
for (const rule of rules) {
|
|
9597
|
+
if (rule.pattern.test(entry.name)) {
|
|
9598
|
+
findings.push({ file: displayPath, line: 0, rule: rule.label });
|
|
9599
|
+
break;
|
|
9600
|
+
}
|
|
9601
|
+
}
|
|
9243
9602
|
}
|
|
9244
|
-
return
|
|
9603
|
+
return findings;
|
|
9245
9604
|
}
|
|
9246
|
-
|
|
9247
|
-
|
|
9248
|
-
|
|
9249
|
-
|
|
9250
|
-
|
|
9251
|
-
|
|
9252
|
-
|
|
9253
|
-
|
|
9254
|
-
|
|
9255
|
-
|
|
9605
|
+
async function flagGrepProofMedia(root, repoRoot = root) {
|
|
9606
|
+
const files = await collectScannableFiles(root, repoRoot, GREP_PROOF_EXTENSIONS);
|
|
9607
|
+
return files.map(([, displayPath]) => ({
|
|
9608
|
+
file: displayPath,
|
|
9609
|
+
reason: "binary media format -- text extraction is unreliable; review manually for embedded identifiers"
|
|
9610
|
+
}));
|
|
9611
|
+
}
|
|
9612
|
+
async function runWard(repoRoot, options = {}) {
|
|
9613
|
+
const userTerms = options.terms ?? [];
|
|
9614
|
+
const exclusions = options.exclusions ?? [];
|
|
9615
|
+
const extensions = [...WARD_TEXT_EXTENSIONS, ...options.binaryExtensions ?? []];
|
|
9616
|
+
const userRules = createDenylistRules(userTerms, "ward-term");
|
|
9617
|
+
const vendorRules = createDenylistRules([...VENDOR_IDENTIFIER_PROTECTION_LIST], "ward-vendor");
|
|
9618
|
+
const allRules = [...userRules, ...vendorRules];
|
|
9619
|
+
const contentFiles = await collectScannableFiles(repoRoot, repoRoot, extensions);
|
|
9620
|
+
const fileLines = /* @__PURE__ */ new Map();
|
|
9621
|
+
const contentFindings = [];
|
|
9622
|
+
for (const [absolutePath, displayPath] of contentFiles) {
|
|
9623
|
+
const findings2 = await scanFileAllRules(absolutePath, displayPath, allRules);
|
|
9624
|
+
if (findings2.length > 0) {
|
|
9625
|
+
try {
|
|
9626
|
+
fileLines.set(displayPath, (await readFile20(absolutePath, "utf8")).split("\n"));
|
|
9627
|
+
} catch {
|
|
9628
|
+
}
|
|
9256
9629
|
}
|
|
9630
|
+
contentFindings.push(...findings2);
|
|
9257
9631
|
}
|
|
9258
|
-
|
|
9259
|
-
|
|
9260
|
-
|
|
9261
|
-
|
|
9262
|
-
|
|
9263
|
-
|
|
9264
|
-
|
|
9265
|
-
|
|
9266
|
-
|
|
9267
|
-
|
|
9268
|
-
|
|
9269
|
-
|
|
9270
|
-
|
|
9271
|
-
|
|
9272
|
-
|
|
9273
|
-
|
|
9274
|
-
|
|
9632
|
+
const filenameFindings = await scanFilenames(repoRoot, allRules);
|
|
9633
|
+
const combined = [...contentFindings, ...filenameFindings];
|
|
9634
|
+
const excluded = applyExclusions(combined, fileLines, exclusions);
|
|
9635
|
+
const vendorLabels = new Set(vendorRules.map((r) => r.label));
|
|
9636
|
+
const findings = excluded.map((f) => ({
|
|
9637
|
+
...f,
|
|
9638
|
+
category: vendorLabels.has(f.rule) ? "protected-vendor-identifier" : "leak"
|
|
9639
|
+
}));
|
|
9640
|
+
const mediaFlags = await flagGrepProofMedia(repoRoot);
|
|
9641
|
+
return { findings, mediaFlags };
|
|
9642
|
+
}
|
|
9643
|
+
|
|
9644
|
+
// src/commands/ward.ts
|
|
9645
|
+
function parseTerms(raw) {
|
|
9646
|
+
if (!raw) return [];
|
|
9647
|
+
return raw.split(",").map((t) => t.trim()).filter(Boolean);
|
|
9648
|
+
}
|
|
9649
|
+
function parseTermsFileContent(raw) {
|
|
9650
|
+
return raw.split(/[,\r\n]+/).map((t) => t.trim()).filter(Boolean);
|
|
9651
|
+
}
|
|
9652
|
+
async function readTermsFile(filePath) {
|
|
9653
|
+
try {
|
|
9654
|
+
return parseTermsFileContent(await readFile21(filePath, "utf8"));
|
|
9655
|
+
} catch (error2) {
|
|
9656
|
+
console.warn(
|
|
9657
|
+
`\u26A0 arcane: could not read --terms-file "${filePath}": ${error2.message}`
|
|
9658
|
+
);
|
|
9659
|
+
return [];
|
|
9275
9660
|
}
|
|
9276
|
-
|
|
9277
|
-
|
|
9278
|
-
|
|
9279
|
-
|
|
9280
|
-
|
|
9281
|
-
|
|
9282
|
-
|
|
9283
|
-
|
|
9284
|
-
|
|
9285
|
-
|
|
9286
|
-
|
|
9287
|
-
|
|
9288
|
-
this.guard("write", () => this.backend.set(chunkName(gen, n), chunk));
|
|
9289
|
-
});
|
|
9290
|
-
const index = { v: INDEX_VERSION, gen, count: chunks.length, sha256: sha256Hex(secret) };
|
|
9291
|
-
this.guard("write", () => this.backend.set(INDEX_NAME, JSON.stringify(index)));
|
|
9292
|
-
try {
|
|
9293
|
-
this.sweep(gen);
|
|
9294
|
-
} catch {
|
|
9295
|
-
}
|
|
9661
|
+
}
|
|
9662
|
+
async function runWardCli(targetDir, options = {}, exclusions = []) {
|
|
9663
|
+
const terms = [
|
|
9664
|
+
...parseTerms(options.terms),
|
|
9665
|
+
...options.termsFile ? await readTermsFile(options.termsFile) : []
|
|
9666
|
+
];
|
|
9667
|
+
const report = await runWard(targetDir, { terms, exclusions });
|
|
9668
|
+
console.log("\nspell ward \u2014 scanning for leaked identifiers\n");
|
|
9669
|
+
const leaks = report.findings.filter((f) => f.category === "leak");
|
|
9670
|
+
const protectedHits = report.findings.filter((f) => f.category === "protected-vendor-identifier");
|
|
9671
|
+
if (leaks.length === 0 && protectedHits.length === 0) {
|
|
9672
|
+
console.log(" \u2713 No denylisted terms found.");
|
|
9296
9673
|
}
|
|
9297
|
-
|
|
9298
|
-
|
|
9299
|
-
|
|
9300
|
-
this.sweep(null);
|
|
9674
|
+
for (const finding of leaks) {
|
|
9675
|
+
const where = finding.line === 0 ? `${finding.file} (filename)` : `${finding.file}:${finding.line}`;
|
|
9676
|
+
console.log(` \u2717 [leak] ${where} \u2014 rule: ${finding.rule}`);
|
|
9301
9677
|
}
|
|
9302
|
-
|
|
9303
|
-
|
|
9304
|
-
|
|
9305
|
-
|
|
9306
|
-
|
|
9307
|
-
|
|
9308
|
-
|
|
9678
|
+
for (const finding of protectedHits) {
|
|
9679
|
+
const where = finding.line === 0 ? `${finding.file} (filename)` : `${finding.file}:${finding.line}`;
|
|
9680
|
+
console.log(
|
|
9681
|
+
` \u26A0 [protected-vendor-identifier] ${where} \u2014 rule: ${finding.rule} \u2014 verify before any automated rename touches this line`
|
|
9682
|
+
);
|
|
9683
|
+
}
|
|
9684
|
+
if (report.mediaFlags.length > 0) {
|
|
9685
|
+
console.log("\n Grep-proof media flagged for manual review:");
|
|
9686
|
+
for (const flag of report.mediaFlags) {
|
|
9687
|
+
console.log(` ? ${flag.file} \u2014 ${flag.reason}`);
|
|
9309
9688
|
}
|
|
9310
9689
|
}
|
|
9311
|
-
|
|
9312
|
-
|
|
9313
|
-
|
|
9314
|
-
|
|
9315
|
-
|
|
9316
|
-
|
|
9317
|
-
|
|
9318
|
-
|
|
9319
|
-
}
|
|
9320
|
-
|
|
9321
|
-
production: defineIdentityEnvironment({
|
|
9322
|
-
environment: "production",
|
|
9323
|
-
tenantId: "9f8edbfb-291c-4c4a-b9cc-8820a04f8973",
|
|
9324
|
-
signInHost: "arcaneai.ciamlogin.com",
|
|
9325
|
-
clientId: "7cd8f05d-9979-4458-8ca2-47c67748c41f"
|
|
9326
|
-
}),
|
|
9327
|
-
development: defineIdentityEnvironment({
|
|
9328
|
-
environment: "development",
|
|
9329
|
-
tenantId: "b365de0e-3026-4afe-bf5b-556fccb4ffcf",
|
|
9330
|
-
signInHost: "arcaneaidev.ciamlogin.com",
|
|
9331
|
-
clientId: "651be720-83cc-452b-8346-abf20713ad12"
|
|
9332
|
-
})
|
|
9333
|
-
};
|
|
9334
|
-
var SIGN_IN_SCOPES = ["openid", "profile", "email", "offline_access"];
|
|
9335
|
-
var ENVIRONMENT_VARIABLE = "ARCANE_ENVIRONMENT";
|
|
9336
|
-
var UnknownEnvironmentError = class extends Error {
|
|
9337
|
-
constructor(value) {
|
|
9338
|
-
super(
|
|
9339
|
-
`${ENVIRONMENT_VARIABLE}=${JSON.stringify(value)} is not an environment. Use "production" (the default) or "development".`
|
|
9340
|
-
);
|
|
9341
|
-
this.name = "UnknownEnvironmentError";
|
|
9690
|
+
console.log();
|
|
9691
|
+
if (options.gate && leaks.length > 0) {
|
|
9692
|
+
console.log(` ${leaks.length} leak(s) found. Failing (--gate mode).
|
|
9693
|
+
`);
|
|
9694
|
+
process.exitCode = 1;
|
|
9695
|
+
return;
|
|
9696
|
+
}
|
|
9697
|
+
if (leaks.length > 0) {
|
|
9698
|
+
console.log(` ${leaks.length} leak(s) found. Run with --gate to fail CI on this.
|
|
9699
|
+
`);
|
|
9342
9700
|
}
|
|
9343
|
-
}
|
|
9344
|
-
|
|
9345
|
-
|
|
9346
|
-
|
|
9347
|
-
|
|
9348
|
-
|
|
9701
|
+
}
|
|
9702
|
+
|
|
9703
|
+
// src/commands/unblock-push.ts
|
|
9704
|
+
import { basename as basename3, resolve as resolve8 } from "path";
|
|
9705
|
+
import { input as input4 } from "@inquirer/prompts";
|
|
9706
|
+
async function runUnblockPush(targetDir) {
|
|
9707
|
+
let manifest;
|
|
9708
|
+
try {
|
|
9709
|
+
manifest = await readManifest(targetDir);
|
|
9710
|
+
} catch (err) {
|
|
9711
|
+
if (err instanceof ManifestNotFoundError) {
|
|
9712
|
+
console.error('Not initialized. Run "spell init" first.');
|
|
9713
|
+
process.exit(1);
|
|
9714
|
+
return;
|
|
9715
|
+
}
|
|
9716
|
+
throw err;
|
|
9717
|
+
}
|
|
9718
|
+
const policy = manifest.push_policy ?? "open";
|
|
9719
|
+
const stillInForce = await isHookEnforced(targetDir) || (await blockedRemotes(targetDir)).length > 0;
|
|
9720
|
+
if (policy === "open" && !stillInForce) {
|
|
9721
|
+
printInfo("This repository is not push-blocked \u2014 nothing to undo.");
|
|
9722
|
+
return;
|
|
9723
|
+
}
|
|
9724
|
+
if (policy === "open") {
|
|
9725
|
+
printWarning(
|
|
9726
|
+
"The manifest says push_policy: open, but this repository still has Arcane's push block in force \u2014 most likely a previous unblock only partly succeeded. Continuing so it can be finished."
|
|
9727
|
+
);
|
|
9728
|
+
}
|
|
9729
|
+
if (!process.stdin.isTTY) {
|
|
9730
|
+
console.error(
|
|
9731
|
+
"spell unblock-push must be run from an interactive terminal.\nLifting a push block is a deliberate decision, so it cannot be scripted, piped, or run in CI."
|
|
9732
|
+
);
|
|
9733
|
+
process.exit(1);
|
|
9734
|
+
return;
|
|
9735
|
+
}
|
|
9736
|
+
const repoName = basename3(resolve8(targetDir));
|
|
9737
|
+
if (repoName === "") {
|
|
9738
|
+
console.error(
|
|
9739
|
+
"Could not determine a repository name to confirm against (is this repository at a filesystem root?). Refusing to unblock."
|
|
9740
|
+
);
|
|
9741
|
+
process.exit(1);
|
|
9742
|
+
return;
|
|
9743
|
+
}
|
|
9744
|
+
printWarning(`This repository's push_policy is "${policy}".`);
|
|
9745
|
+
printInfo(
|
|
9746
|
+
"Unblocking restores the ability to push this repository's full history to its remote. Deleting a file later does not undo a push: the content stays in history, in any clone already made, and in the remote's own backups."
|
|
9747
|
+
);
|
|
9748
|
+
console.log();
|
|
9749
|
+
const typed = await input4({
|
|
9750
|
+
message: `Type the repository name to confirm (${repoName}):`
|
|
9751
|
+
});
|
|
9752
|
+
if (typed.trim() !== repoName) {
|
|
9753
|
+
console.error(`
|
|
9754
|
+
Name did not match ("${typed.trim()}" \u2260 "${repoName}"). Nothing changed.`);
|
|
9755
|
+
process.exit(1);
|
|
9756
|
+
return;
|
|
9757
|
+
}
|
|
9758
|
+
await removePrePushHook(targetDir);
|
|
9759
|
+
const results = await restorePushUrls(targetDir);
|
|
9760
|
+
const restored = results.filter((r) => r.status === "restored").map((r) => r.remote);
|
|
9761
|
+
const failed = results.filter((r) => r.status === "failed");
|
|
9762
|
+
const stillBlocked = await blockedRemotes(targetDir);
|
|
9763
|
+
const updated = {
|
|
9764
|
+
...manifest,
|
|
9765
|
+
push_policy: "open",
|
|
9766
|
+
push_policy_unblocked_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
9767
|
+
};
|
|
9768
|
+
await writeManifest(targetDir, updated);
|
|
9769
|
+
const hookStillOn = await isHookEnforced(targetDir);
|
|
9770
|
+
const fullyLifted = failed.length === 0 && stillBlocked.length === 0 && !hookStillOn;
|
|
9771
|
+
if (fullyLifted) {
|
|
9772
|
+
printSuccess(`Push unblocked for "${repoName}".`);
|
|
9773
|
+
} else {
|
|
9774
|
+
printWarning(
|
|
9775
|
+
`Push only PARTIALLY unblocked for "${repoName}". The manifest now says open; the repository is not fully open yet. Details below.`
|
|
9776
|
+
);
|
|
9777
|
+
}
|
|
9778
|
+
if (restored.length > 0) {
|
|
9779
|
+
printInfo(`Restored push URL for: ${restored.join(", ")}.`);
|
|
9780
|
+
}
|
|
9781
|
+
if (hookStillOn) {
|
|
9782
|
+
printWarning(
|
|
9783
|
+
"The pre-push hook is still in force. Arcane did not remove it because `core.hooksPath` is not one it owns, could not be read, or names a directory that a link takes outside this repository. Check `git config --show-scope --get core.hooksPath`."
|
|
9784
|
+
);
|
|
9785
|
+
}
|
|
9786
|
+
if (failed.length > 0) {
|
|
9787
|
+
printWarning(
|
|
9788
|
+
`Could not restore the push URL for: ${failed.map((r) => `${r.remote} (${r.reason ?? "unknown error"})`).join("; ")}.`
|
|
9789
|
+
);
|
|
9790
|
+
}
|
|
9791
|
+
if (stillBlocked.length > 0) {
|
|
9792
|
+
printWarning(
|
|
9793
|
+
`These remotes still have a blocked push URL and no recorded original: ${stillBlocked.join(", ")}. Set them yourself with \`git remote set-url --push <name> <url>\` \u2014 until then, pushing to them will fail even though the manifest now says open.`
|
|
9794
|
+
);
|
|
9795
|
+
}
|
|
9796
|
+
printInfo(
|
|
9797
|
+
"Recorded in .arcane.json as push_policy: open, with the time it was lifted. Commit that change so the repository's actual posture stays visible to everyone."
|
|
9798
|
+
);
|
|
9799
|
+
}
|
|
9800
|
+
|
|
9801
|
+
// src/commands/block-push.ts
|
|
9802
|
+
async function runBlockPush(targetDir) {
|
|
9803
|
+
let manifest;
|
|
9804
|
+
try {
|
|
9805
|
+
manifest = await readManifest(targetDir);
|
|
9806
|
+
} catch (err) {
|
|
9807
|
+
if (err instanceof ManifestNotFoundError) {
|
|
9808
|
+
console.error('Not initialized. Run "spell init" first.');
|
|
9809
|
+
process.exit(1);
|
|
9810
|
+
return;
|
|
9811
|
+
}
|
|
9812
|
+
throw err;
|
|
9813
|
+
}
|
|
9814
|
+
if (manifest.scope === "user") {
|
|
9815
|
+
console.error(
|
|
9816
|
+
"spell block-push applies to a repository, not the user tier: push_policy describes a repository."
|
|
9817
|
+
);
|
|
9818
|
+
process.exit(1);
|
|
9819
|
+
return;
|
|
9820
|
+
}
|
|
9821
|
+
if ((await inspectGitRepository(targetDir)).status === "not-repository") {
|
|
9822
|
+
console.error("spell block-push needs a Git repository: there is nothing here to block.");
|
|
9823
|
+
process.exit(1);
|
|
9824
|
+
return;
|
|
9825
|
+
}
|
|
9826
|
+
const recorded = manifest.push_policy ?? "open";
|
|
9827
|
+
const alreadyEnforced = recorded === "blocked" && await isHookEnforced(targetDir) && (await undisabledRemotes(targetDir)).length === 0;
|
|
9828
|
+
if (alreadyEnforced) {
|
|
9829
|
+
printSuccess("Push is already blocked and enforced here. Nothing changed.");
|
|
9830
|
+
if ((await listRemotes(targetDir)).length === 0) {
|
|
9831
|
+
printWarning(
|
|
9832
|
+
"No remote is configured, so only the pre-push hook is active. Re-run `spell block-push` after adding a remote to disable its push URL too."
|
|
9833
|
+
);
|
|
9834
|
+
}
|
|
9835
|
+
printInfo("Run `spell unblock-push` from a terminal to undo this.");
|
|
9836
|
+
return;
|
|
9837
|
+
}
|
|
9838
|
+
const outcome = await applyBlockedPushControls(targetDir, {
|
|
9839
|
+
stopOnHookRefusal: true,
|
|
9840
|
+
emit: (m) => m.level === "warning" ? printWarning(m.text) : printInfo(m.text)
|
|
9841
|
+
});
|
|
9842
|
+
const failedRemotes = outcome.urls.filter((u) => u.status === "failed");
|
|
9843
|
+
if (!hookInForce(outcome.hook) || failedRemotes.length > 0) {
|
|
9844
|
+
printWarning(
|
|
9845
|
+
outcome.stoppedAtHook ? "Nothing was changed. Resolve the refusal above and re-run `spell block-push`." : "Push is NOT fully blocked: the remotes named above can still be pushed to. Fix them and re-run `spell block-push`."
|
|
9846
|
+
);
|
|
9847
|
+
if (recorded !== "blocked") {
|
|
9848
|
+
printInfo(
|
|
9849
|
+
`The manifest still records push_policy: "${recorded}" \u2014 it is not changed to "blocked" until both controls are in force.`
|
|
9850
|
+
);
|
|
9851
|
+
}
|
|
9852
|
+
process.exit(1);
|
|
9853
|
+
return;
|
|
9854
|
+
}
|
|
9855
|
+
if (recorded !== "blocked") {
|
|
9856
|
+
await writeManifest(targetDir, { ...manifest, push_policy: "blocked" });
|
|
9857
|
+
printSuccess(`Push blocked. Recorded push_policy: "blocked" (was "${recorded}") in .arcane.json.`);
|
|
9858
|
+
printInfo("Commit that change so the repository's posture stays visible to everyone.");
|
|
9859
|
+
} else {
|
|
9860
|
+
printSuccess('Push blocked. The manifest already recorded push_policy: "blocked"; it is now enforced.');
|
|
9861
|
+
}
|
|
9862
|
+
}
|
|
9863
|
+
|
|
9864
|
+
// src/modules/identity-config.ts
|
|
9865
|
+
function defineIdentityEnvironment(config) {
|
|
9866
|
+
return {
|
|
9867
|
+
...config,
|
|
9868
|
+
discoveryUrl: `https://${config.signInHost}/${config.tenantId}/v2.0/.well-known/openid-configuration`
|
|
9869
|
+
};
|
|
9870
|
+
}
|
|
9871
|
+
var IDENTITY_ENVIRONMENTS = {
|
|
9872
|
+
production: defineIdentityEnvironment({
|
|
9873
|
+
environment: "production",
|
|
9874
|
+
tenantId: "9f8edbfb-291c-4c4a-b9cc-8820a04f8973",
|
|
9875
|
+
signInHost: "arcaneai.ciamlogin.com",
|
|
9876
|
+
clientId: "7cd8f05d-9979-4458-8ca2-47c67748c41f"
|
|
9877
|
+
}),
|
|
9878
|
+
development: defineIdentityEnvironment({
|
|
9879
|
+
environment: "development",
|
|
9880
|
+
tenantId: "b365de0e-3026-4afe-bf5b-556fccb4ffcf",
|
|
9881
|
+
signInHost: "arcaneaidev.ciamlogin.com",
|
|
9882
|
+
clientId: "651be720-83cc-452b-8346-abf20713ad12"
|
|
9883
|
+
})
|
|
9884
|
+
};
|
|
9885
|
+
var SIGN_IN_SCOPES = ["openid", "profile", "email", "offline_access"];
|
|
9886
|
+
var ENVIRONMENT_VARIABLE = "ARCANE_ENVIRONMENT";
|
|
9887
|
+
var UnknownEnvironmentError = class extends Error {
|
|
9888
|
+
constructor(value) {
|
|
9889
|
+
super(
|
|
9890
|
+
`${ENVIRONMENT_VARIABLE}=${JSON.stringify(value)} is not an environment. Use "production" (the default) or "development".`
|
|
9891
|
+
);
|
|
9892
|
+
this.name = "UnknownEnvironmentError";
|
|
9893
|
+
}
|
|
9894
|
+
};
|
|
9895
|
+
function resolveIdentityEnvironment(env = process.env) {
|
|
9896
|
+
const raw = (env[ENVIRONMENT_VARIABLE] ?? "").trim().toLowerCase();
|
|
9897
|
+
if (raw === "" || raw === "prod" || raw === "production") return IDENTITY_ENVIRONMENTS.production;
|
|
9898
|
+
if (raw === "dev" || raw === "development") return IDENTITY_ENVIRONMENTS.development;
|
|
9899
|
+
throw new UnknownEnvironmentError(env[ENVIRONMENT_VARIABLE] ?? "");
|
|
9349
9900
|
}
|
|
9350
9901
|
|
|
9351
9902
|
// src/modules/oidc.ts
|
|
@@ -9356,10 +9907,10 @@ import * as client from "openid-client";
|
|
|
9356
9907
|
// src/modules/result-pages.ts
|
|
9357
9908
|
import { createHash as createHash5 } from "crypto";
|
|
9358
9909
|
import { readFile as readFile22 } from "fs/promises";
|
|
9359
|
-
import { join as
|
|
9910
|
+
import { join as join26 } from "path";
|
|
9360
9911
|
var RESULT_KINDS = ["success", "denied", "failure"];
|
|
9361
9912
|
function resultPagePath(kind) {
|
|
9362
|
-
return
|
|
9913
|
+
return join26("sign-in", `sign-in-callback-${kind}.html`);
|
|
9363
9914
|
}
|
|
9364
9915
|
function styleHashes(html) {
|
|
9365
9916
|
return [...html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi)].map((match2) => {
|
|
@@ -9393,429 +9944,226 @@ function plainPage(heading, next) {
|
|
|
9393
9944
|
[
|
|
9394
9945
|
'<!doctype html><html lang="en"><meta charset="utf-8">',
|
|
9395
9946
|
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
9396
|
-
`<title>${heading} \u2014 Arcane</title>`,
|
|
9397
|
-
"<style>:root{color-scheme:light dark}body{font-family:system-ui,sans-serif;margin:3rem;line-height:1.5}</style>",
|
|
9398
|
-
`<body><h1>${heading}</h1><p>${next}</p></body></html>`
|
|
9399
|
-
].join("")
|
|
9400
|
-
);
|
|
9401
|
-
}
|
|
9402
|
-
var PLAIN_RESULT_PAGES = {
|
|
9403
|
-
success: plainPage("Signed in to Arcane", "Return to your terminal. Your browser can be closed now."),
|
|
9404
|
-
denied: plainPage("Sign-in was not completed", "Return to your terminal to try again. No new session was stored."),
|
|
9405
|
-
failure: plainPage("Could not finish sign-in", "Return to your terminal for details. No new session was stored.")
|
|
9406
|
-
};
|
|
9407
|
-
async function loadResultPages(assetsDir) {
|
|
9408
|
-
try {
|
|
9409
|
-
const [success, denied, failure] = await Promise.all(
|
|
9410
|
-
RESULT_KINDS.map(async (kind) => resultPage(await readFile22(join24(assetsDir, resultPagePath(kind)), "utf8")))
|
|
9411
|
-
);
|
|
9412
|
-
return { success, denied, failure };
|
|
9413
|
-
} catch {
|
|
9414
|
-
return null;
|
|
9415
|
-
}
|
|
9416
|
-
}
|
|
9417
|
-
|
|
9418
|
-
// src/modules/oidc.ts
|
|
9419
|
-
var IdentityUnreachableError = class extends Error {
|
|
9420
|
-
constructor(host, cause) {
|
|
9421
|
-
super(`Could not reach the identity service at ${host}.`);
|
|
9422
|
-
this.name = "IdentityUnreachableError";
|
|
9423
|
-
this.cause = cause;
|
|
9424
|
-
}
|
|
9425
|
-
};
|
|
9426
|
-
var LoginTimeoutError = class extends Error {
|
|
9427
|
-
constructor() {
|
|
9428
|
-
super("Timed out waiting for the sign-in to finish in the browser.");
|
|
9429
|
-
this.name = "LoginTimeoutError";
|
|
9430
|
-
}
|
|
9431
|
-
};
|
|
9432
|
-
var LoginBrowserUnavailableError = class extends Error {
|
|
9433
|
-
constructor() {
|
|
9434
|
-
super("No browser could be opened. Run `spell login --device-code` and finish signing in on another device.");
|
|
9435
|
-
this.name = "LoginBrowserUnavailableError";
|
|
9436
|
-
}
|
|
9437
|
-
};
|
|
9438
|
-
var LoginDeniedError = class extends Error {
|
|
9439
|
-
constructor(code) {
|
|
9440
|
-
super(`The identity service refused the sign-in (${code}).`);
|
|
9441
|
-
this.name = "LoginDeniedError";
|
|
9442
|
-
}
|
|
9443
|
-
};
|
|
9444
|
-
async function loadIdentityConfiguration(environment, options = {}) {
|
|
9445
|
-
const fetchImpl = options.fetch ?? fetch;
|
|
9446
|
-
let metadata;
|
|
9447
|
-
try {
|
|
9448
|
-
const response = await fetchImpl(environment.discoveryUrl, { redirect: "manual" });
|
|
9449
|
-
if (!response.ok) throw new Error(`discovery returned HTTP ${response.status}`);
|
|
9450
|
-
metadata = await response.json();
|
|
9451
|
-
} catch (error2) {
|
|
9452
|
-
throw new IdentityUnreachableError(environment.signInHost, error2);
|
|
9453
|
-
}
|
|
9454
|
-
if (typeof metadata.issuer !== "string" || !metadata.token_endpoint || !metadata.authorization_endpoint) {
|
|
9455
|
-
throw new IdentityUnreachableError(environment.signInHost, new Error("discovery document incomplete"));
|
|
9456
|
-
}
|
|
9457
|
-
const config = new client.Configuration(metadata, environment.clientId, void 0, client.None());
|
|
9458
|
-
if (options.fetch) config[client.customFetch] = options.fetch;
|
|
9459
|
-
if (options.allowInsecureHttp) client.allowInsecureRequests(config);
|
|
9460
|
-
return config;
|
|
9461
|
-
}
|
|
9462
|
-
function toTokenResult(tokens, previousRefreshToken) {
|
|
9463
|
-
const claims = tokens.claims();
|
|
9464
|
-
if (!claims?.sub) throw new Error("The identity service returned no account identifier.");
|
|
9465
|
-
const refreshToken = tokens.refresh_token ?? previousRefreshToken;
|
|
9466
|
-
if (!refreshToken) {
|
|
9467
|
-
throw new Error("The identity service returned no refresh token; nothing can be stored.");
|
|
9468
|
-
}
|
|
9469
|
-
const now = /* @__PURE__ */ new Date();
|
|
9470
|
-
const seconds = tokens.expiresIn() ?? 0;
|
|
9471
|
-
const email = typeof claims["email"] === "string" ? claims["email"] : typeof claims["preferred_username"] === "string" ? claims["preferred_username"] : null;
|
|
9472
|
-
return {
|
|
9473
|
-
refreshToken,
|
|
9474
|
-
sub: claims.sub,
|
|
9475
|
-
email,
|
|
9476
|
-
obtainedAt: now,
|
|
9477
|
-
expiresAt: new Date(now.getTime() + seconds * 1e3)
|
|
9478
|
-
};
|
|
9479
|
-
}
|
|
9480
|
-
var resultPath = (kind) => `/result/${kind}`;
|
|
9481
|
-
var RESULT_PATHS = new Set(RESULT_KINDS.map(resultPath));
|
|
9482
|
-
async function browserLogin(config, options) {
|
|
9483
|
-
const codeVerifier = client.randomPKCECodeVerifier();
|
|
9484
|
-
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
|
|
9485
|
-
const state = client.randomState();
|
|
9486
|
-
const nonce = client.randomNonce();
|
|
9487
|
-
const server = createServer();
|
|
9488
|
-
await new Promise((resolve10, reject) => {
|
|
9489
|
-
server.once("error", reject);
|
|
9490
|
-
server.listen(0, "127.0.0.1", () => resolve10());
|
|
9491
|
-
});
|
|
9492
|
-
const address = server.address();
|
|
9493
|
-
if (address === null || typeof address === "string") {
|
|
9494
|
-
server.close();
|
|
9495
|
-
throw new Error("Could not open a loopback port for the sign-in.");
|
|
9496
|
-
}
|
|
9497
|
-
const redirectUri = new URL(`http://127.0.0.1:${address.port}`).href;
|
|
9498
|
-
const authorizationUrl = client.buildAuthorizationUrl(config, {
|
|
9499
|
-
redirect_uri: redirectUri,
|
|
9500
|
-
scope: options.scopes.join(" "),
|
|
9501
|
-
code_challenge: codeChallenge,
|
|
9502
|
-
code_challenge_method: "S256",
|
|
9503
|
-
state,
|
|
9504
|
-
nonce
|
|
9505
|
-
});
|
|
9506
|
-
const pages = options.pages ?? PLAIN_RESULT_PAGES;
|
|
9507
|
-
const timeoutMs = options.timeoutMs ?? 5 * 60 * 1e3;
|
|
9508
|
-
let timer;
|
|
9509
|
-
let callbackConsumed = false;
|
|
9510
|
-
let browserResult;
|
|
9511
|
-
const close = () => {
|
|
9512
|
-
if (timer) clearTimeout(timer);
|
|
9513
|
-
server.closeAllConnections?.();
|
|
9514
|
-
server.close();
|
|
9515
|
-
};
|
|
9516
|
-
const tokens = new Promise((resolve10, reject) => {
|
|
9517
|
-
server.on("request", (req, res) => {
|
|
9518
|
-
const url = new URL(req.url ?? "/", redirectUri);
|
|
9519
|
-
if (RESULT_PATHS.has(url.pathname)) {
|
|
9520
|
-
if (!browserResult || url.pathname !== resultPath(browserResult.kind) || url.search !== "") {
|
|
9521
|
-
res.writeHead(404).end();
|
|
9522
|
-
return;
|
|
9523
|
-
}
|
|
9524
|
-
const served = browserResult;
|
|
9525
|
-
const page = pages[served.kind];
|
|
9526
|
-
const settle = () => {
|
|
9527
|
-
if (served.kind === "success") resolve10(served.result);
|
|
9528
|
-
else reject(served.error);
|
|
9529
|
-
};
|
|
9530
|
-
res.once("finish", settle).once("close", settle);
|
|
9531
|
-
res.writeHead(200, page.headers).end(page.html);
|
|
9532
|
-
return;
|
|
9533
|
-
}
|
|
9534
|
-
if (url.pathname !== "/" || !url.searchParams.has("code") && !url.searchParams.has("error")) {
|
|
9535
|
-
res.writeHead(404).end();
|
|
9536
|
-
return;
|
|
9537
|
-
}
|
|
9538
|
-
if (callbackConsumed) {
|
|
9539
|
-
res.writeHead(409, { "cache-control": "no-store" }).end();
|
|
9540
|
-
return;
|
|
9541
|
-
}
|
|
9542
|
-
if (url.searchParams.get("state") !== state) {
|
|
9543
|
-
res.writeHead(400, pages.failure.headers).end(pages.failure.html);
|
|
9544
|
-
return;
|
|
9545
|
-
}
|
|
9546
|
-
callbackConsumed = true;
|
|
9547
|
-
const redirectToResult = (result) => {
|
|
9548
|
-
browserResult = result;
|
|
9549
|
-
res.writeHead(303, {
|
|
9550
|
-
"cache-control": "no-store",
|
|
9551
|
-
location: resultPath(result.kind),
|
|
9552
|
-
"referrer-policy": "no-referrer"
|
|
9553
|
-
}).end();
|
|
9554
|
-
if (result.kind === "success") {
|
|
9555
|
-
if (timer) clearTimeout(timer);
|
|
9556
|
-
timer = setTimeout(() => resolve10(result.result), Math.min(timeoutMs, 5e3));
|
|
9557
|
-
}
|
|
9558
|
-
};
|
|
9559
|
-
const errorCode = url.searchParams.get("error");
|
|
9560
|
-
if (errorCode) {
|
|
9561
|
-
const code = errorCode.replace(/[^a-z_]/gi, "");
|
|
9562
|
-
redirectToResult({
|
|
9563
|
-
kind: code === "access_denied" ? "denied" : "failure",
|
|
9564
|
-
error: new LoginDeniedError(code)
|
|
9565
|
-
});
|
|
9566
|
-
return;
|
|
9567
|
-
}
|
|
9568
|
-
client.authorizationCodeGrant(config, url, {
|
|
9569
|
-
pkceCodeVerifier: codeVerifier,
|
|
9570
|
-
expectedState: state,
|
|
9571
|
-
expectedNonce: nonce,
|
|
9572
|
-
idTokenExpected: true
|
|
9573
|
-
}).then(async (response) => {
|
|
9574
|
-
const result = toTokenResult(response);
|
|
9575
|
-
await options.onValidated?.(result);
|
|
9576
|
-
redirectToResult({ kind: "success", result });
|
|
9577
|
-
}).catch((error2) => {
|
|
9578
|
-
redirectToResult({ kind: "failure", error: error2 });
|
|
9579
|
-
});
|
|
9580
|
-
});
|
|
9581
|
-
timer = setTimeout(() => reject(new LoginTimeoutError()), timeoutMs);
|
|
9582
|
-
});
|
|
9583
|
-
tokens.catch(() => {
|
|
9584
|
-
});
|
|
9585
|
-
try {
|
|
9586
|
-
const opened = await options.openUrl(authorizationUrl.href);
|
|
9587
|
-
if (!opened) {
|
|
9588
|
-
options.onCannotOpen?.();
|
|
9589
|
-
throw new LoginBrowserUnavailableError();
|
|
9590
|
-
}
|
|
9591
|
-
return await tokens;
|
|
9592
|
-
} finally {
|
|
9593
|
-
close();
|
|
9594
|
-
}
|
|
9595
|
-
}
|
|
9596
|
-
async function deviceLogin(config, options) {
|
|
9597
|
-
const handle = await client.initiateDeviceAuthorization(config, { scope: options.scopes.join(" ") });
|
|
9598
|
-
options.prompt(handle.verification_uri, handle.user_code, handle.expires_in);
|
|
9599
|
-
const response = await client.pollDeviceAuthorizationGrant(config, handle);
|
|
9600
|
-
return toTokenResult(response);
|
|
9601
|
-
}
|
|
9602
|
-
async function refreshSession(config, refreshToken, scopes) {
|
|
9603
|
-
const response = await client.refreshTokenGrant(config, refreshToken, { scope: scopes.join(" ") });
|
|
9604
|
-
return toTokenResult(response, refreshToken);
|
|
9605
|
-
}
|
|
9606
|
-
function openInDefaultBrowser(url) {
|
|
9607
|
-
const [command, args] = process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
|
|
9608
|
-
return new Promise((resolve10) => {
|
|
9609
|
-
try {
|
|
9610
|
-
const child = execFile3(command, args, { windowsHide: true }, (error2) => resolve10(!error2));
|
|
9611
|
-
child.on("error", () => resolve10(false));
|
|
9612
|
-
} catch {
|
|
9613
|
-
resolve10(false);
|
|
9614
|
-
}
|
|
9615
|
-
});
|
|
9616
|
-
}
|
|
9617
|
-
|
|
9618
|
-
// src/modules/session-store.ts
|
|
9619
|
-
import { execFileSync } from "child_process";
|
|
9620
|
-
import { mkdirSync, readFileSync as readFileSync2, renameSync, rmSync, writeFileSync, existsSync as existsSync2 } from "fs";
|
|
9621
|
-
import { userInfo } from "os";
|
|
9622
|
-
import { dirname as dirname12, join as join25 } from "path";
|
|
9623
|
-
|
|
9624
|
-
// src/modules/keychain-backend.ts
|
|
9625
|
-
import { createRequire as createRequire2 } from "module";
|
|
9626
|
-
var KEYCHAIN_SERVICE = "arcane-cli";
|
|
9627
|
-
function createKeychainBackend(service = KEYCHAIN_SERVICE) {
|
|
9628
|
-
let loaded = null;
|
|
9629
|
-
const keyring = () => {
|
|
9630
|
-
loaded ??= createRequire2(import.meta.url)("@napi-rs/keyring");
|
|
9631
|
-
return loaded;
|
|
9632
|
-
};
|
|
9633
|
-
const entry = (name) => new (keyring()).Entry(service, name);
|
|
9634
|
-
return {
|
|
9635
|
-
get: (name) => entry(name).getPassword() ?? null,
|
|
9636
|
-
set: (name, value) => {
|
|
9637
|
-
entry(name).setPassword(value);
|
|
9638
|
-
},
|
|
9639
|
-
delete: (name) => {
|
|
9640
|
-
entry(name).deletePassword();
|
|
9641
|
-
},
|
|
9642
|
-
list: () => keyring().findCredentials(service).map((credential) => credential.account)
|
|
9643
|
-
};
|
|
9644
|
-
}
|
|
9645
|
-
|
|
9646
|
-
// src/modules/session-store.ts
|
|
9647
|
-
var INSECURE_SESSION_FILE_NAME = "session-insecure.json";
|
|
9648
|
-
var SessionClearError = class extends Error {
|
|
9649
|
-
constructor(keychainError, removed) {
|
|
9650
|
-
super(keychainError.message);
|
|
9651
|
-
this.keychainError = keychainError;
|
|
9652
|
-
this.removed = removed;
|
|
9653
|
-
this.name = "SessionClearError";
|
|
9654
|
-
this.cause = keychainError;
|
|
9655
|
-
}
|
|
9656
|
-
keychainError;
|
|
9657
|
-
removed;
|
|
9658
|
-
};
|
|
9659
|
-
function isIsoDate(value) {
|
|
9660
|
-
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
|
9947
|
+
`<title>${heading} \u2014 Arcane</title>`,
|
|
9948
|
+
"<style>:root{color-scheme:light dark}body{font-family:system-ui,sans-serif;margin:3rem;line-height:1.5}</style>",
|
|
9949
|
+
`<body><h1>${heading}</h1><p>${next}</p></body></html>`
|
|
9950
|
+
].join("")
|
|
9951
|
+
);
|
|
9661
9952
|
}
|
|
9662
|
-
|
|
9663
|
-
|
|
9953
|
+
var PLAIN_RESULT_PAGES = {
|
|
9954
|
+
success: plainPage("Signed in to Arcane", "Return to your terminal. Your browser can be closed now."),
|
|
9955
|
+
denied: plainPage("Sign-in was not completed", "Return to your terminal to try again. No new session was stored."),
|
|
9956
|
+
failure: plainPage("Could not finish sign-in", "Return to your terminal for details. No new session was stored.")
|
|
9957
|
+
};
|
|
9958
|
+
async function loadResultPages(assetsDir) {
|
|
9664
9959
|
try {
|
|
9665
|
-
|
|
9960
|
+
const [success, denied, failure] = await Promise.all(
|
|
9961
|
+
RESULT_KINDS.map(async (kind) => resultPage(await readFile22(join26(assetsDir, resultPagePath(kind)), "utf8")))
|
|
9962
|
+
);
|
|
9963
|
+
return { success, denied, failure };
|
|
9666
9964
|
} catch {
|
|
9667
9965
|
return null;
|
|
9668
9966
|
}
|
|
9669
|
-
if (typeof parsed !== "object" || parsed === null) return null;
|
|
9670
|
-
const o = parsed;
|
|
9671
|
-
if (o["v"] !== 1 || o["environment"] !== "production" && o["environment"] !== "development" || typeof o["sub"] !== "string" || o["sub"] === "" || !(typeof o["email"] === "string" || o["email"] === null) || typeof o["refreshToken"] !== "string" || o["refreshToken"] === "" || !isIsoDate(o["obtainedAt"]) || !isIsoDate(o["expiresAt"])) {
|
|
9672
|
-
return null;
|
|
9673
|
-
}
|
|
9674
|
-
return {
|
|
9675
|
-
v: 1,
|
|
9676
|
-
environment: o["environment"],
|
|
9677
|
-
sub: o["sub"],
|
|
9678
|
-
email: o["email"],
|
|
9679
|
-
refreshToken: o["refreshToken"],
|
|
9680
|
-
obtainedAt: o["obtainedAt"],
|
|
9681
|
-
expiresAt: o["expiresAt"]
|
|
9682
|
-
};
|
|
9683
9967
|
}
|
|
9684
|
-
|
|
9685
|
-
|
|
9686
|
-
|
|
9687
|
-
|
|
9688
|
-
|
|
9689
|
-
|
|
9690
|
-
|
|
9691
|
-
exists() {
|
|
9692
|
-
return existsSync2(this.filePath);
|
|
9968
|
+
|
|
9969
|
+
// src/modules/oidc.ts
|
|
9970
|
+
var IdentityUnreachableError = class extends Error {
|
|
9971
|
+
constructor(host, cause) {
|
|
9972
|
+
super(`Could not reach the identity service at ${host}.`);
|
|
9973
|
+
this.name = "IdentityUnreachableError";
|
|
9974
|
+
this.cause = cause;
|
|
9693
9975
|
}
|
|
9694
|
-
|
|
9695
|
-
|
|
9696
|
-
|
|
9697
|
-
|
|
9698
|
-
|
|
9699
|
-
}
|
|
9700
|
-
const out = {};
|
|
9701
|
-
for (const [k, v] of Object.entries(parsed)) {
|
|
9702
|
-
if (typeof v === "string") out[k] = v;
|
|
9703
|
-
}
|
|
9704
|
-
return out;
|
|
9976
|
+
};
|
|
9977
|
+
var LoginTimeoutError = class extends Error {
|
|
9978
|
+
constructor() {
|
|
9979
|
+
super("Timed out waiting for the sign-in to finish in the browser.");
|
|
9980
|
+
this.name = "LoginTimeoutError";
|
|
9705
9981
|
}
|
|
9706
|
-
|
|
9707
|
-
|
|
9708
|
-
|
|
9709
|
-
|
|
9710
|
-
|
|
9711
|
-
mkdirSync(dirname12(this.filePath), { recursive: true });
|
|
9712
|
-
const tmp = `${this.filePath}.${process.pid}.tmp`;
|
|
9713
|
-
writeFileSync(tmp, JSON.stringify(entries), { encoding: "utf8", mode: 384 });
|
|
9714
|
-
try {
|
|
9715
|
-
this.restrict(tmp);
|
|
9716
|
-
} catch (error2) {
|
|
9717
|
-
rmSync(tmp, { force: true });
|
|
9718
|
-
throw new Error(
|
|
9719
|
-
"Could not restrict the session file to your account, so it was not written.",
|
|
9720
|
-
{ cause: error2 }
|
|
9721
|
-
);
|
|
9722
|
-
}
|
|
9723
|
-
renameSync(tmp, this.filePath);
|
|
9982
|
+
};
|
|
9983
|
+
var LoginBrowserUnavailableError = class extends Error {
|
|
9984
|
+
constructor() {
|
|
9985
|
+
super("No browser could be opened. Run `spell login --device-code` and finish signing in on another device.");
|
|
9986
|
+
this.name = "LoginBrowserUnavailableError";
|
|
9724
9987
|
}
|
|
9725
|
-
|
|
9726
|
-
|
|
9988
|
+
};
|
|
9989
|
+
var LoginDeniedError = class extends Error {
|
|
9990
|
+
constructor(code) {
|
|
9991
|
+
super(`The identity service refused the sign-in (${code}).`);
|
|
9992
|
+
this.name = "LoginDeniedError";
|
|
9727
9993
|
}
|
|
9728
|
-
|
|
9729
|
-
|
|
9730
|
-
|
|
9731
|
-
|
|
9994
|
+
};
|
|
9995
|
+
async function loadIdentityConfiguration(environment, options = {}) {
|
|
9996
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
9997
|
+
let metadata;
|
|
9998
|
+
try {
|
|
9999
|
+
const response = await fetchImpl(environment.discoveryUrl, { redirect: "manual" });
|
|
10000
|
+
if (!response.ok) throw new Error(`discovery returned HTTP ${response.status}`);
|
|
10001
|
+
metadata = await response.json();
|
|
10002
|
+
} catch (error2) {
|
|
10003
|
+
throw new IdentityUnreachableError(environment.signInHost, error2);
|
|
9732
10004
|
}
|
|
9733
|
-
|
|
9734
|
-
|
|
9735
|
-
if (!(name in entries)) return;
|
|
9736
|
-
delete entries[name];
|
|
9737
|
-
this.write(entries);
|
|
10005
|
+
if (typeof metadata.issuer !== "string" || !metadata.token_endpoint || !metadata.authorization_endpoint) {
|
|
10006
|
+
throw new IdentityUnreachableError(environment.signInHost, new Error("discovery document incomplete"));
|
|
9738
10007
|
}
|
|
9739
|
-
|
|
9740
|
-
|
|
10008
|
+
const config = new client.Configuration(metadata, environment.clientId, void 0, client.None());
|
|
10009
|
+
if (options.fetch) config[client.customFetch] = options.fetch;
|
|
10010
|
+
if (options.allowInsecureHttp) client.allowInsecureRequests(config);
|
|
10011
|
+
return config;
|
|
10012
|
+
}
|
|
10013
|
+
function toTokenResult(tokens, previousRefreshToken) {
|
|
10014
|
+
const claims = tokens.claims();
|
|
10015
|
+
if (!claims?.sub) throw new Error("The identity service returned no account identifier.");
|
|
10016
|
+
const refreshToken = tokens.refresh_token ?? previousRefreshToken;
|
|
10017
|
+
if (!refreshToken) {
|
|
10018
|
+
throw new Error("The identity service returned no refresh token; nothing can be stored.");
|
|
9741
10019
|
}
|
|
9742
|
-
|
|
9743
|
-
|
|
9744
|
-
|
|
10020
|
+
const now = /* @__PURE__ */ new Date();
|
|
10021
|
+
const seconds = tokens.expiresIn() ?? 0;
|
|
10022
|
+
const email = typeof claims["email"] === "string" ? claims["email"] : typeof claims["preferred_username"] === "string" ? claims["preferred_username"] : null;
|
|
10023
|
+
return {
|
|
10024
|
+
refreshToken,
|
|
10025
|
+
sub: claims.sub,
|
|
10026
|
+
email,
|
|
10027
|
+
obtainedAt: now,
|
|
10028
|
+
expiresAt: new Date(now.getTime() + seconds * 1e3)
|
|
10029
|
+
};
|
|
10030
|
+
}
|
|
10031
|
+
var resultPath = (kind) => `/result/${kind}`;
|
|
10032
|
+
var RESULT_PATHS = new Set(RESULT_KINDS.map(resultPath));
|
|
10033
|
+
async function browserLogin(config, options) {
|
|
10034
|
+
const codeVerifier = client.randomPKCECodeVerifier();
|
|
10035
|
+
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
|
|
10036
|
+
const state = client.randomState();
|
|
10037
|
+
const nonce = client.randomNonce();
|
|
10038
|
+
const server = createServer();
|
|
10039
|
+
await new Promise((resolve10, reject) => {
|
|
10040
|
+
server.once("error", reject);
|
|
10041
|
+
server.listen(0, "127.0.0.1", () => resolve10());
|
|
10042
|
+
});
|
|
10043
|
+
const address = server.address();
|
|
10044
|
+
if (address === null || typeof address === "string") {
|
|
10045
|
+
server.close();
|
|
10046
|
+
throw new Error("Could not open a loopback port for the sign-in.");
|
|
9745
10047
|
}
|
|
9746
|
-
};
|
|
9747
|
-
|
|
9748
|
-
|
|
9749
|
-
|
|
9750
|
-
|
|
9751
|
-
|
|
9752
|
-
|
|
10048
|
+
const redirectUri = new URL(`http://127.0.0.1:${address.port}`).href;
|
|
10049
|
+
const authorizationUrl = client.buildAuthorizationUrl(config, {
|
|
10050
|
+
redirect_uri: redirectUri,
|
|
10051
|
+
scope: options.scopes.join(" "),
|
|
10052
|
+
code_challenge: codeChallenge,
|
|
10053
|
+
code_challenge_method: "S256",
|
|
10054
|
+
state,
|
|
10055
|
+
nonce
|
|
9753
10056
|
});
|
|
9754
|
-
|
|
9755
|
-
|
|
9756
|
-
|
|
9757
|
-
|
|
9758
|
-
|
|
9759
|
-
const
|
|
9760
|
-
|
|
9761
|
-
|
|
9762
|
-
|
|
9763
|
-
if (result.state === "signed-out") return null;
|
|
9764
|
-
if (result.state === "corrupt") return { state: "corrupt", source, reason: result.reason };
|
|
9765
|
-
const record = parseSessionRecord(result.secret);
|
|
9766
|
-
if (record === null) return { state: "corrupt", source, reason: "the stored session is not valid" };
|
|
9767
|
-
return { state: "ok", source, record };
|
|
10057
|
+
const pages = options.pages ?? PLAIN_RESULT_PAGES;
|
|
10058
|
+
const timeoutMs = options.timeoutMs ?? 5 * 60 * 1e3;
|
|
10059
|
+
let timer;
|
|
10060
|
+
let callbackConsumed = false;
|
|
10061
|
+
let browserResult;
|
|
10062
|
+
const close = () => {
|
|
10063
|
+
if (timer) clearTimeout(timer);
|
|
10064
|
+
server.closeAllConnections?.();
|
|
10065
|
+
server.close();
|
|
9768
10066
|
};
|
|
9769
|
-
|
|
9770
|
-
|
|
9771
|
-
|
|
9772
|
-
|
|
9773
|
-
|
|
9774
|
-
|
|
9775
|
-
|
|
9776
|
-
if (!(error2 instanceof KeychainUnavailableError)) throw error2;
|
|
9777
|
-
keychainError = error2;
|
|
9778
|
-
}
|
|
9779
|
-
if (fileBackend.exists()) {
|
|
9780
|
-
const fromFile = interpret(file.load(), "file");
|
|
9781
|
-
if (fromFile) return fromFile;
|
|
9782
|
-
}
|
|
9783
|
-
return keychainError ? { state: "signed-out", keychainError } : { state: "signed-out" };
|
|
9784
|
-
},
|
|
9785
|
-
save(record, { insecure }) {
|
|
9786
|
-
const serialized = JSON.stringify(record);
|
|
9787
|
-
if (insecure) {
|
|
9788
|
-
file.save(serialized);
|
|
9789
|
-
try {
|
|
9790
|
-
keychain.clear();
|
|
9791
|
-
} catch {
|
|
10067
|
+
const tokens = new Promise((resolve10, reject) => {
|
|
10068
|
+
server.on("request", (req, res) => {
|
|
10069
|
+
const url = new URL(req.url ?? "/", redirectUri);
|
|
10070
|
+
if (RESULT_PATHS.has(url.pathname)) {
|
|
10071
|
+
if (!browserResult || url.pathname !== resultPath(browserResult.kind) || url.search !== "") {
|
|
10072
|
+
res.writeHead(404).end();
|
|
10073
|
+
return;
|
|
9792
10074
|
}
|
|
10075
|
+
const served = browserResult;
|
|
10076
|
+
const page = pages[served.kind];
|
|
10077
|
+
const settle = () => {
|
|
10078
|
+
if (served.kind === "success") resolve10(served.result);
|
|
10079
|
+
else reject(served.error);
|
|
10080
|
+
};
|
|
10081
|
+
res.once("finish", settle).once("close", settle);
|
|
10082
|
+
res.writeHead(200, page.headers).end(page.html);
|
|
9793
10083
|
return;
|
|
9794
10084
|
}
|
|
9795
|
-
|
|
9796
|
-
|
|
9797
|
-
|
|
9798
|
-
clear() {
|
|
9799
|
-
const removed = [];
|
|
9800
|
-
let keychainError;
|
|
9801
|
-
try {
|
|
9802
|
-
const hadKeychainSession = keychain.load().state !== "signed-out";
|
|
9803
|
-
keychain.clear();
|
|
9804
|
-
if (hadKeychainSession) removed.push("keychain");
|
|
9805
|
-
} catch (error2) {
|
|
9806
|
-
keychainError = error2;
|
|
10085
|
+
if (url.pathname !== "/" || !url.searchParams.has("code") && !url.searchParams.has("error")) {
|
|
10086
|
+
res.writeHead(404).end();
|
|
10087
|
+
return;
|
|
9807
10088
|
}
|
|
9808
|
-
if (
|
|
9809
|
-
|
|
9810
|
-
|
|
10089
|
+
if (callbackConsumed) {
|
|
10090
|
+
res.writeHead(409, { "cache-control": "no-store" }).end();
|
|
10091
|
+
return;
|
|
10092
|
+
}
|
|
10093
|
+
if (url.searchParams.get("state") !== state) {
|
|
10094
|
+
res.writeHead(400, pages.failure.headers).end(pages.failure.html);
|
|
10095
|
+
return;
|
|
9811
10096
|
}
|
|
9812
|
-
|
|
9813
|
-
|
|
10097
|
+
callbackConsumed = true;
|
|
10098
|
+
const redirectToResult = (result) => {
|
|
10099
|
+
browserResult = result;
|
|
10100
|
+
res.writeHead(303, {
|
|
10101
|
+
"cache-control": "no-store",
|
|
10102
|
+
location: resultPath(result.kind),
|
|
10103
|
+
"referrer-policy": "no-referrer"
|
|
10104
|
+
}).end();
|
|
10105
|
+
if (result.kind === "success") {
|
|
10106
|
+
if (timer) clearTimeout(timer);
|
|
10107
|
+
timer = setTimeout(() => resolve10(result.result), Math.min(timeoutMs, 5e3));
|
|
10108
|
+
}
|
|
10109
|
+
};
|
|
10110
|
+
const errorCode = url.searchParams.get("error");
|
|
10111
|
+
if (errorCode) {
|
|
10112
|
+
const code = errorCode.replace(/[^a-z_]/gi, "");
|
|
10113
|
+
redirectToResult({
|
|
10114
|
+
kind: code === "access_denied" ? "denied" : "failure",
|
|
10115
|
+
error: new LoginDeniedError(code)
|
|
10116
|
+
});
|
|
10117
|
+
return;
|
|
9814
10118
|
}
|
|
9815
|
-
|
|
9816
|
-
|
|
10119
|
+
client.authorizationCodeGrant(config, url, {
|
|
10120
|
+
pkceCodeVerifier: codeVerifier,
|
|
10121
|
+
expectedState: state,
|
|
10122
|
+
expectedNonce: nonce,
|
|
10123
|
+
idTokenExpected: true
|
|
10124
|
+
}).then(async (response) => {
|
|
10125
|
+
const result = toTokenResult(response);
|
|
10126
|
+
await options.onValidated?.(result);
|
|
10127
|
+
redirectToResult({ kind: "success", result });
|
|
10128
|
+
}).catch((error2) => {
|
|
10129
|
+
redirectToResult({ kind: "failure", error: error2 });
|
|
10130
|
+
});
|
|
10131
|
+
});
|
|
10132
|
+
timer = setTimeout(() => reject(new LoginTimeoutError()), timeoutMs);
|
|
10133
|
+
});
|
|
10134
|
+
tokens.catch(() => {
|
|
10135
|
+
});
|
|
10136
|
+
try {
|
|
10137
|
+
const opened = await options.openUrl(authorizationUrl.href);
|
|
10138
|
+
if (!opened) {
|
|
10139
|
+
options.onCannotOpen?.();
|
|
10140
|
+
throw new LoginBrowserUnavailableError();
|
|
9817
10141
|
}
|
|
9818
|
-
|
|
10142
|
+
return await tokens;
|
|
10143
|
+
} finally {
|
|
10144
|
+
close();
|
|
10145
|
+
}
|
|
10146
|
+
}
|
|
10147
|
+
async function deviceLogin(config, options) {
|
|
10148
|
+
const handle = await client.initiateDeviceAuthorization(config, { scope: options.scopes.join(" ") });
|
|
10149
|
+
options.prompt(handle.verification_uri, handle.user_code, handle.expires_in);
|
|
10150
|
+
const response = await client.pollDeviceAuthorizationGrant(config, handle);
|
|
10151
|
+
return toTokenResult(response);
|
|
10152
|
+
}
|
|
10153
|
+
async function refreshSession(config, refreshToken, scopes) {
|
|
10154
|
+
const response = await client.refreshTokenGrant(config, refreshToken, { scope: scopes.join(" ") });
|
|
10155
|
+
return toTokenResult(response, refreshToken);
|
|
10156
|
+
}
|
|
10157
|
+
function openInDefaultBrowser(url) {
|
|
10158
|
+
const [command, args] = process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
|
|
10159
|
+
return new Promise((resolve10) => {
|
|
10160
|
+
try {
|
|
10161
|
+
const child = execFile3(command, args, { windowsHide: true }, (error2) => resolve10(!error2));
|
|
10162
|
+
child.on("error", () => resolve10(false));
|
|
10163
|
+
} catch {
|
|
10164
|
+
resolve10(false);
|
|
10165
|
+
}
|
|
10166
|
+
});
|
|
9819
10167
|
}
|
|
9820
10168
|
|
|
9821
10169
|
// src/commands/login.ts
|
|
@@ -10021,15 +10369,15 @@ async function runWhoami(options, deps = defaultWhoamiDeps()) {
|
|
|
10021
10369
|
|
|
10022
10370
|
// src/commands/report.ts
|
|
10023
10371
|
import { access as access5 } from "fs/promises";
|
|
10024
|
-
import { join as
|
|
10372
|
+
import { join as join29 } from "path";
|
|
10025
10373
|
|
|
10026
10374
|
// src/modules/show-report/generate.ts
|
|
10027
10375
|
import { mkdir as mkdir8, readdir as readdir13, readFile as readFile24, writeFile as writeFile11 } from "fs/promises";
|
|
10028
|
-
import { basename as basename4, dirname as dirname13, isAbsolute, join as
|
|
10376
|
+
import { basename as basename4, dirname as dirname13, isAbsolute, join as join28, relative as relative4, resolve as resolve9 } from "path";
|
|
10029
10377
|
|
|
10030
10378
|
// src/modules/show-report/model.ts
|
|
10031
10379
|
import { readFile as readFile23 } from "fs/promises";
|
|
10032
|
-
import { join as
|
|
10380
|
+
import { join as join27 } from "path";
|
|
10033
10381
|
|
|
10034
10382
|
// src/modules/show-report/plan-parser.ts
|
|
10035
10383
|
function parseFrontmatter(content) {
|
|
@@ -10376,11 +10724,11 @@ function buildCorrections(ledgerRows, programName) {
|
|
|
10376
10724
|
};
|
|
10377
10725
|
}
|
|
10378
10726
|
async function buildShowReportModel(options) {
|
|
10379
|
-
const planAbsPath =
|
|
10380
|
-
const queueAbsPath =
|
|
10727
|
+
const planAbsPath = join27(options.rootDir, options.planRelPath);
|
|
10728
|
+
const queueAbsPath = join27(options.rootDir, options.queueRelPath);
|
|
10381
10729
|
const ledgerRelPath = "docs/verification-ledger.md";
|
|
10382
|
-
const ledgerAbsPath =
|
|
10383
|
-
const decisionsAbsPath =
|
|
10730
|
+
const ledgerAbsPath = join27(options.rootDir, ledgerRelPath);
|
|
10731
|
+
const decisionsAbsPath = join27(options.rootDir, "DECISIONS.md");
|
|
10384
10732
|
const [planContent, queueContent, ledgerContent, decisionsContent] = await Promise.all([
|
|
10385
10733
|
readFile23(planAbsPath, "utf8"),
|
|
10386
10734
|
readOptional(queueAbsPath),
|
|
@@ -10578,7 +10926,7 @@ function toPosix(path5) {
|
|
|
10578
10926
|
async function programFromPlan(rootDir, slug, planRelPath) {
|
|
10579
10927
|
let planContent;
|
|
10580
10928
|
try {
|
|
10581
|
-
planContent = await readFile24(
|
|
10929
|
+
planContent = await readFile24(join28(rootDir, planRelPath), "utf8");
|
|
10582
10930
|
} catch {
|
|
10583
10931
|
return { skipped: { slug, reason: `no PLAN.md at ${planRelPath}` } };
|
|
10584
10932
|
}
|
|
@@ -10590,13 +10938,13 @@ async function programFromPlan(rootDir, slug, planRelPath) {
|
|
|
10590
10938
|
program: {
|
|
10591
10939
|
slug,
|
|
10592
10940
|
planRelPath,
|
|
10593
|
-
queueRelPath: toPosix(
|
|
10941
|
+
queueRelPath: toPosix(join28(dirname13(planRelPath), "OPERATOR-QUEUE.md")),
|
|
10594
10942
|
ledgerProgramName: programNameFromTitle(frontmatter.title ?? slug)
|
|
10595
10943
|
}
|
|
10596
10944
|
};
|
|
10597
10945
|
}
|
|
10598
10946
|
async function discoverPrograms(rootDir) {
|
|
10599
|
-
const plansDir =
|
|
10947
|
+
const plansDir = join28(rootDir, "docs", "plans");
|
|
10600
10948
|
let entries;
|
|
10601
10949
|
try {
|
|
10602
10950
|
entries = (await readdir13(plansDir, { withFileTypes: true })).filter((e) => e.isDirectory());
|
|
@@ -10615,7 +10963,7 @@ async function discoverPrograms(rootDir) {
|
|
|
10615
10963
|
}
|
|
10616
10964
|
async function programFromPlanPath(rootDir, planPath) {
|
|
10617
10965
|
const planAbs = isAbsolute(planPath) ? planPath : resolve9(rootDir, planPath);
|
|
10618
|
-
const planRelPath = toPosix(
|
|
10966
|
+
const planRelPath = toPosix(relative4(rootDir, planAbs));
|
|
10619
10967
|
return programFromPlan(rootDir, basename4(dirname13(planAbs)), planRelPath);
|
|
10620
10968
|
}
|
|
10621
10969
|
function normalizeLineEndings2(content) {
|
|
@@ -10630,7 +10978,7 @@ async function readOptionalFile(path5) {
|
|
|
10630
10978
|
}
|
|
10631
10979
|
}
|
|
10632
10980
|
async function generateReport(rootDir, program2, templateHtml, theme = "auto") {
|
|
10633
|
-
const planContent = await readFile24(
|
|
10981
|
+
const planContent = await readFile24(join28(rootDir, program2.planRelPath), "utf8");
|
|
10634
10982
|
const frontmatter = parseFrontmatter(planContent);
|
|
10635
10983
|
const model = await buildShowReportModel({
|
|
10636
10984
|
rootDir,
|
|
@@ -10662,18 +11010,18 @@ async function runReportGeneration(options) {
|
|
|
10662
11010
|
const missing = JSON.parse(generated.json).sections.flatMap((section) => section.rows).filter((row) => row.descriptionState === "unwritten").map((row) => row.id);
|
|
10663
11011
|
if (missing.length > 0) unwritten.push({ slug: program2.slug, ids: missing });
|
|
10664
11012
|
const outAbs = options.outDir ? isAbsolute(options.outDir) ? options.outDir : resolve9(options.rootDir, options.outDir) : resolve9(options.rootDir, dirname13(program2.planRelPath));
|
|
10665
|
-
const outRel = toPosix(
|
|
11013
|
+
const outRel = toPosix(relative4(options.rootDir, outAbs));
|
|
10666
11014
|
const outputs = [
|
|
10667
11015
|
[`${outRel}/show-report.json`, generated.json],
|
|
10668
11016
|
[`${outRel}/show-report.html`, generated.html]
|
|
10669
11017
|
];
|
|
10670
11018
|
for (const [relPath, expected] of outputs) {
|
|
10671
|
-
const actual = await readOptionalFile(
|
|
11019
|
+
const actual = await readOptionalFile(join28(options.rootDir, relPath));
|
|
10672
11020
|
if (actual === null || normalizeLineEndings2(actual) !== normalizeLineEndings2(expected)) {
|
|
10673
11021
|
drifted.push(`${relPath} does not match a regeneration from its sources.`);
|
|
10674
11022
|
if (options.mode === "write") {
|
|
10675
11023
|
await mkdir8(outAbs, { recursive: true });
|
|
10676
|
-
await writeFile11(
|
|
11024
|
+
await writeFile11(join28(options.rootDir, relPath), expected, "utf8");
|
|
10677
11025
|
repaired.push(relPath);
|
|
10678
11026
|
}
|
|
10679
11027
|
}
|
|
@@ -10684,7 +11032,7 @@ async function runReportGeneration(options) {
|
|
|
10684
11032
|
|
|
10685
11033
|
// src/commands/report.ts
|
|
10686
11034
|
async function runReport(targetDir, options = {}, assetsDir) {
|
|
10687
|
-
const templatePath =
|
|
11035
|
+
const templatePath = join29(assetsDir, "report", "show-report.template.html");
|
|
10688
11036
|
try {
|
|
10689
11037
|
await access5(templatePath);
|
|
10690
11038
|
} catch {
|
|
@@ -10736,325 +11084,101 @@ async function runReport(targetDir, options = {}, assetsDir) {
|
|
|
10736
11084
|
templatePath,
|
|
10737
11085
|
mode: "write",
|
|
10738
11086
|
programs,
|
|
10739
|
-
outDir: options.out,
|
|
10740
|
-
theme
|
|
10741
|
-
});
|
|
10742
|
-
for (const u of result.unwritten) {
|
|
10743
|
-
console.warn(
|
|
10744
|
-
`\u26A0 arcane: ${u.slug} has ${u.ids.length} epic(s) with no \`**Report:**\` line in PLAN.md -- they render as "unwritten": ${u.ids.join(", ")}.`
|
|
10745
|
-
);
|
|
10746
|
-
}
|
|
10747
|
-
for (const slug of result.themeSkipped) {
|
|
10748
|
-
console.warn(
|
|
10749
|
-
`\u26A0 arcane: ${slug} -- --theme ${theme} requested, but this template has no theme toggle to set (older vendored template?); left unchanged.`
|
|
10750
|
-
);
|
|
10751
|
-
}
|
|
10752
|
-
if (result.repaired.length === 0) {
|
|
10753
|
-
console.log(`Show report: ${programs.length} program(s) already up to date.`);
|
|
10754
|
-
return;
|
|
10755
|
-
}
|
|
10756
|
-
console.log(`Show report: wrote ${result.repaired.length} file(s).`);
|
|
10757
|
-
for (const path5 of result.repaired) console.log(` ${path5}`);
|
|
10758
|
-
}
|
|
10759
|
-
|
|
10760
|
-
// src/modules/agent-ref.ts
|
|
10761
|
-
import { randomBytes as randomBytes2 } from "crypto";
|
|
10762
|
-
var REF_TYPES = ["agent", "template", "session"];
|
|
10763
|
-
var UUID_V7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
10764
|
-
var ANY_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
10765
|
-
function createUuidV7Generator(options = {}) {
|
|
10766
|
-
const now = options.now ?? Date.now;
|
|
10767
|
-
const random = options.random ?? ((n) => randomBytes2(n));
|
|
10768
|
-
let lastMs = -1;
|
|
10769
|
-
let counter = 0;
|
|
10770
|
-
return () => {
|
|
10771
|
-
let ms = Math.floor(now());
|
|
10772
|
-
const bytes = random(10);
|
|
10773
|
-
if (ms > lastMs) {
|
|
10774
|
-
counter = (bytes[0] << 8 | bytes[1]) & 2047;
|
|
10775
|
-
} else {
|
|
10776
|
-
ms = lastMs;
|
|
10777
|
-
counter += 1;
|
|
10778
|
-
if (counter > 4095) {
|
|
10779
|
-
ms += 1;
|
|
10780
|
-
counter = (bytes[0] << 8 | bytes[1]) & 2047;
|
|
10781
|
-
}
|
|
10782
|
-
}
|
|
10783
|
-
lastMs = ms;
|
|
10784
|
-
const out = new Uint8Array(16);
|
|
10785
|
-
let t = ms;
|
|
10786
|
-
for (let i = 5; i >= 0; i -= 1) {
|
|
10787
|
-
out[i] = t % 256;
|
|
10788
|
-
t = Math.floor(t / 256);
|
|
10789
|
-
}
|
|
10790
|
-
out[6] = 112 | counter >> 8 & 15;
|
|
10791
|
-
out[7] = counter & 255;
|
|
10792
|
-
out[8] = 128 | bytes[2] & 63;
|
|
10793
|
-
for (let i = 9; i < 16; i += 1) out[i] = bytes[i - 6];
|
|
10794
|
-
const hex = Array.from(out, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
10795
|
-
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
10796
|
-
};
|
|
10797
|
-
}
|
|
10798
|
-
var defaultUuidV7 = createUuidV7Generator();
|
|
10799
|
-
function uuidv7() {
|
|
10800
|
-
return defaultUuidV7();
|
|
10801
|
-
}
|
|
10802
|
-
function mintRef(type, generate = uuidv7) {
|
|
10803
|
-
if (!REF_TYPES.includes(type)) {
|
|
10804
|
-
throw new Error(`"${String(type)}" is not a registered reference type`);
|
|
10805
|
-
}
|
|
10806
|
-
return `arcane:${type}:${generate()}`;
|
|
10807
|
-
}
|
|
10808
|
-
function mintSessionRef() {
|
|
10809
|
-
return mintRef("session");
|
|
10810
|
-
}
|
|
10811
|
-
function parseRef(text2, expected) {
|
|
10812
|
-
if (typeof text2 !== "string") return { ok: false, reason: "a reference must be a string" };
|
|
10813
|
-
if (text2.length === 0) return { ok: false, reason: "a reference must not be empty" };
|
|
10814
|
-
if (text2 !== text2.trim()) return { ok: false, reason: "a reference must not carry surrounding whitespace" };
|
|
10815
|
-
if (text2 !== text2.toLowerCase()) return { ok: false, reason: "a reference must be lowercase" };
|
|
10816
|
-
const parts = text2.split(":");
|
|
10817
|
-
if (parts.length !== 3 || parts[0] !== "arcane") {
|
|
10818
|
-
return { ok: false, reason: 'a reference must have the form "arcane:<type>:<uuid>"' };
|
|
10819
|
-
}
|
|
10820
|
-
const type = parts[1];
|
|
10821
|
-
const uuid = parts[2];
|
|
10822
|
-
if (!REF_TYPES.includes(type)) {
|
|
10823
|
-
return { ok: false, reason: `"${type}" is not a registered reference type` };
|
|
10824
|
-
}
|
|
10825
|
-
if (uuid.startsWith("{") || uuid.endsWith("}")) {
|
|
10826
|
-
return { ok: false, reason: "a reference's uuid must not be braced" };
|
|
10827
|
-
}
|
|
10828
|
-
if (!ANY_UUID.test(uuid)) return { ok: false, reason: "a reference's uuid must be in canonical hex form" };
|
|
10829
|
-
if (!UUID_V7.test(uuid)) return { ok: false, reason: "a reference's uuid must be a version 7 UUID" };
|
|
10830
|
-
if (expected !== void 0 && type !== expected) {
|
|
10831
|
-
return { ok: false, reason: `expected a ${expected} reference, got one of type ${type}` };
|
|
10832
|
-
}
|
|
10833
|
-
return { ok: true, type, uuid };
|
|
10834
|
-
}
|
|
10835
|
-
|
|
10836
|
-
// src/modules/presence/client.ts
|
|
10837
|
-
import { request } from "http";
|
|
10838
|
-
|
|
10839
|
-
// src/modules/presence/store.ts
|
|
10840
|
-
import { randomBytes as randomBytes3 } from "crypto";
|
|
10841
|
-
import { chmodSync, existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
10842
|
-
import { join as join29, relative as relative4 } from "path";
|
|
10843
|
-
|
|
10844
|
-
// src/modules/presence/types.ts
|
|
10845
|
-
var PRESENCE_CONTRACT = "v0";
|
|
10846
|
-
var DEFAULT_PORT = 27226;
|
|
10847
|
-
var PRESENCE_ENV = { port: "ARCANE_PRESENCE_PORT", token: "ARCANE_PRESENCE_TOKEN" };
|
|
10848
|
-
var LOOPBACK_HOST = "127.0.0.1";
|
|
10849
|
-
var MAX_BODY_BYTES = 65536;
|
|
10850
|
-
var MAX_LINE_CHARS = 256;
|
|
10851
|
-
var MAX_NEEDS_YOU = 20;
|
|
10852
|
-
var MAX_SUBSCRIBERS = 32;
|
|
10853
|
-
var MAX_SUBSCRIBER_BUFFER_BYTES = 1048576;
|
|
10854
|
-
var MAX_CONNECTIONS = 128;
|
|
10855
|
-
var MAX_SESSIONS = 256;
|
|
10856
|
-
var MAX_ALIASES = 32;
|
|
10857
|
-
var CONNECTIONS_CHECK_MS = 1e3;
|
|
10858
|
-
var HEADERS_TIMEOUT_MS = 5e3;
|
|
10859
|
-
var REQUEST_TIMEOUT_MS = 1e4;
|
|
10860
|
-
var STALE_AFTER_MS = 9e4;
|
|
10861
|
-
var EXPIRE_AFTER_MS = 864e5;
|
|
10862
|
-
var KEEPALIVE_MS = 15e3;
|
|
10863
|
-
var SWEEP_MS = 5e3;
|
|
10864
|
-
var HARNESSES = ["claude-code", "copilot", "codex", "openclaw", "cron", "other"];
|
|
10865
|
-
var REPORT_STATUSES = ["working", "waiting", "blocked", "idle"];
|
|
10866
|
-
var ALIAS_CONTEXTS = ["openclaw", "slug"];
|
|
10867
|
-
var HELLO_EVENT = "server.hello";
|
|
10868
|
-
function presenceCapabilities() {
|
|
10869
|
-
return { observe: true, actions: [] };
|
|
10870
|
-
}
|
|
10871
|
-
function nullAgentBlock() {
|
|
10872
|
-
return { ref: null, slug: null, name: null, origin: null, definition_revision: null, aliases: [] };
|
|
10873
|
-
}
|
|
10874
|
-
|
|
10875
|
-
// src/modules/presence/store.ts
|
|
10876
|
-
var PRESENCE_DIR_NAME = "presence";
|
|
10877
|
-
var STORE_FILES = { token: "token", serverInfo: "server.json" };
|
|
10878
|
-
var PresenceStoreError = class extends Error {
|
|
10879
|
-
};
|
|
10880
|
-
function home(options) {
|
|
10881
|
-
return options.home ?? resolveHomeDir();
|
|
10882
|
-
}
|
|
10883
|
-
function presenceDir(options = {}) {
|
|
10884
|
-
return join29(userTierRoot(home(options)), PRESENCE_DIR_NAME);
|
|
10885
|
-
}
|
|
10886
|
-
function tokenPath(options = {}) {
|
|
10887
|
-
return join29(presenceDir(options), STORE_FILES.token);
|
|
10888
|
-
}
|
|
10889
|
-
function serverInfoPath(options = {}) {
|
|
10890
|
-
return join29(presenceDir(options), STORE_FILES.serverInfo);
|
|
10891
|
-
}
|
|
10892
|
-
function ensureStore(options = {}) {
|
|
10893
|
-
const root = userTierRoot(home(options));
|
|
10894
|
-
const dir = presenceDir(options);
|
|
10895
|
-
mkdirSync2(root, { recursive: true });
|
|
10896
|
-
const realRoot = resolveRealPath(root);
|
|
10897
|
-
if (relative4(realRoot, resolveRealPath(dir)) !== PRESENCE_DIR_NAME) {
|
|
10898
|
-
throw new PresenceStoreError(`Refusing to use ${dir}: it resolves outside ${root}.`);
|
|
10899
|
-
}
|
|
10900
|
-
if (relative4(realRoot, resolveRealPath(tokenPath(options))) !== join29(PRESENCE_DIR_NAME, STORE_FILES.token)) {
|
|
10901
|
-
throw new PresenceStoreError(`Refusing to use ${tokenPath(options)}: it resolves outside ${dir}.`);
|
|
10902
|
-
}
|
|
10903
|
-
mkdirSync2(dir, { recursive: true, mode: 448 });
|
|
10904
|
-
chmodSync(dir, 448);
|
|
10905
|
-
return dir;
|
|
10906
|
-
}
|
|
10907
|
-
function writeRestricted(target, content, options) {
|
|
10908
|
-
const restrict = options.restrict ?? restrictToCurrentUser;
|
|
10909
|
-
const tmp = `${target}.${process.pid}.tmp`;
|
|
10910
|
-
writeFileSync2(tmp, content, { encoding: "utf8", mode: 384 });
|
|
10911
|
-
try {
|
|
10912
|
-
restrict(tmp);
|
|
10913
|
-
} catch (error2) {
|
|
10914
|
-
rmSync2(tmp, { force: true });
|
|
10915
|
-
throw new PresenceStoreError(`Could not restrict ${target} to your account, so it was not written.`, { cause: error2 });
|
|
10916
|
-
}
|
|
10917
|
-
renameSync2(tmp, target);
|
|
10918
|
-
chmodSync(target, 384);
|
|
10919
|
-
}
|
|
10920
|
-
function generateToken() {
|
|
10921
|
-
return randomBytes3(32).toString("base64url");
|
|
10922
|
-
}
|
|
10923
|
-
function readTokenFile(options) {
|
|
10924
|
-
let raw;
|
|
10925
|
-
try {
|
|
10926
|
-
raw = readFileSync3(tokenPath(options), "utf8");
|
|
10927
|
-
} catch {
|
|
10928
|
-
return null;
|
|
11087
|
+
outDir: options.out,
|
|
11088
|
+
theme
|
|
11089
|
+
});
|
|
11090
|
+
for (const u of result.unwritten) {
|
|
11091
|
+
console.warn(
|
|
11092
|
+
`\u26A0 arcane: ${u.slug} has ${u.ids.length} epic(s) with no \`**Report:**\` line in PLAN.md -- they render as "unwritten": ${u.ids.join(", ")}.`
|
|
11093
|
+
);
|
|
10929
11094
|
}
|
|
10930
|
-
const
|
|
10931
|
-
|
|
10932
|
-
}
|
|
10933
|
-
|
|
10934
|
-
ensureStore(options);
|
|
10935
|
-
const existing = options.rotate === true ? null : readTokenFile(options);
|
|
10936
|
-
if (existing !== null) {
|
|
10937
|
-
chmodSync(tokenPath(options), 384);
|
|
10938
|
-
(options.restrict ?? restrictToCurrentUser)(tokenPath(options));
|
|
10939
|
-
return existing;
|
|
11095
|
+
for (const slug of result.themeSkipped) {
|
|
11096
|
+
console.warn(
|
|
11097
|
+
`\u26A0 arcane: ${slug} -- --theme ${theme} requested, but this template has no theme toggle to set (older vendored template?); left unchanged.`
|
|
11098
|
+
);
|
|
10940
11099
|
}
|
|
10941
|
-
|
|
10942
|
-
|
|
10943
|
-
|
|
10944
|
-
return token;
|
|
10945
|
-
}
|
|
10946
|
-
function readToken(options = {}) {
|
|
10947
|
-
const fromEnv = (options.env ?? process.env)[PRESENCE_ENV.token];
|
|
10948
|
-
if (fromEnv !== void 0 && fromEnv.replace(/[\r\n]+$/, "") !== "") return fromEnv.replace(/[\r\n]+$/, "");
|
|
10949
|
-
return readTokenFile(options);
|
|
10950
|
-
}
|
|
10951
|
-
function writeServerInfo(info, options = {}) {
|
|
10952
|
-
ensureStore(options);
|
|
10953
|
-
writeRestricted(serverInfoPath(options), `${JSON.stringify(info, null, 2)}
|
|
10954
|
-
`, options);
|
|
10955
|
-
}
|
|
10956
|
-
function readServerInfo(options = {}) {
|
|
10957
|
-
let parsed;
|
|
10958
|
-
try {
|
|
10959
|
-
parsed = JSON.parse(readFileSync3(serverInfoPath(options), "utf8"));
|
|
10960
|
-
} catch {
|
|
10961
|
-
return null;
|
|
11100
|
+
if (result.repaired.length === 0) {
|
|
11101
|
+
console.log(`Show report: ${programs.length} program(s) already up to date.`);
|
|
11102
|
+
return;
|
|
10962
11103
|
}
|
|
10963
|
-
|
|
10964
|
-
const
|
|
10965
|
-
const generator = o["generator"];
|
|
10966
|
-
const valid = typeof o["url"] === "string" && Number.isInteger(o["pid"]) && o["pid"] > 0 && Number.isInteger(o["port"]) && o["port"] >= 1 && o["port"] <= 65535 && typeof o["started_at"] === "string" && typeof generator === "object" && generator !== null && generator["name"] === "arcane-cli" && typeof generator["version"] === "string";
|
|
10967
|
-
if (!valid) return null;
|
|
10968
|
-
return {
|
|
10969
|
-
url: o["url"],
|
|
10970
|
-
pid: o["pid"],
|
|
10971
|
-
port: o["port"],
|
|
10972
|
-
started_at: o["started_at"],
|
|
10973
|
-
generator: { name: "arcane-cli", version: generator["version"] }
|
|
10974
|
-
};
|
|
10975
|
-
}
|
|
10976
|
-
function removeServerInfo(options = {}) {
|
|
10977
|
-
rmSync2(serverInfoPath(options), { force: true });
|
|
11104
|
+
console.log(`Show report: wrote ${result.repaired.length} file(s).`);
|
|
11105
|
+
for (const path5 of result.repaired) console.log(` ${path5}`);
|
|
10978
11106
|
}
|
|
10979
11107
|
|
|
10980
|
-
// src/modules/
|
|
10981
|
-
|
|
10982
|
-
var
|
|
10983
|
-
|
|
10984
|
-
|
|
10985
|
-
|
|
10986
|
-
|
|
10987
|
-
|
|
10988
|
-
|
|
10989
|
-
|
|
11108
|
+
// src/modules/agent-ref.ts
|
|
11109
|
+
import { randomBytes as randomBytes3 } from "crypto";
|
|
11110
|
+
var REF_TYPES = ["agent", "template", "session"];
|
|
11111
|
+
var UUID_V7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
11112
|
+
var ANY_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
11113
|
+
function createUuidV7Generator(options = {}) {
|
|
11114
|
+
const now = options.now ?? Date.now;
|
|
11115
|
+
const random = options.random ?? ((n) => randomBytes3(n));
|
|
11116
|
+
let lastMs = -1;
|
|
11117
|
+
let counter = 0;
|
|
11118
|
+
return () => {
|
|
11119
|
+
let ms = Math.floor(now());
|
|
11120
|
+
const bytes = random(10);
|
|
11121
|
+
if (ms > lastMs) {
|
|
11122
|
+
counter = (bytes[0] << 8 | bytes[1]) & 2047;
|
|
11123
|
+
} else {
|
|
11124
|
+
ms = lastMs;
|
|
11125
|
+
counter += 1;
|
|
11126
|
+
if (counter > 4095) {
|
|
11127
|
+
ms += 1;
|
|
11128
|
+
counter = (bytes[0] << 8 | bytes[1]) & 2047;
|
|
11129
|
+
}
|
|
11130
|
+
}
|
|
11131
|
+
lastMs = ms;
|
|
11132
|
+
const out = new Uint8Array(16);
|
|
11133
|
+
let t = ms;
|
|
11134
|
+
for (let i = 5; i >= 0; i -= 1) {
|
|
11135
|
+
out[i] = t % 256;
|
|
11136
|
+
t = Math.floor(t / 256);
|
|
11137
|
+
}
|
|
11138
|
+
out[6] = 112 | counter >> 8 & 15;
|
|
11139
|
+
out[7] = counter & 255;
|
|
11140
|
+
out[8] = 128 | bytes[2] & 63;
|
|
11141
|
+
for (let i = 9; i < 16; i += 1) out[i] = bytes[i - 6];
|
|
11142
|
+
const hex = Array.from(out, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
11143
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
11144
|
+
};
|
|
10990
11145
|
}
|
|
10991
|
-
|
|
10992
|
-
|
|
10993
|
-
|
|
10994
|
-
const token = readToken({ ...options, env: options.env ?? process.env });
|
|
10995
|
-
if (token === null) return null;
|
|
10996
|
-
return { info, token };
|
|
11146
|
+
var defaultUuidV7 = createUuidV7Generator();
|
|
11147
|
+
function uuidv7() {
|
|
11148
|
+
return defaultUuidV7();
|
|
10997
11149
|
}
|
|
10998
|
-
function
|
|
10999
|
-
|
|
11000
|
-
|
|
11150
|
+
function mintRef(type, generate = uuidv7) {
|
|
11151
|
+
if (!REF_TYPES.includes(type)) {
|
|
11152
|
+
throw new Error(`"${String(type)}" is not a registered reference type`);
|
|
11153
|
+
}
|
|
11154
|
+
return `arcane:${type}:${generate()}`;
|
|
11001
11155
|
}
|
|
11002
|
-
function
|
|
11003
|
-
|
|
11004
|
-
const responseTimeout = options.responseTimeoutMs ?? RESPONSE_TIMEOUT_MS;
|
|
11005
|
-
const payload = body === void 0 ? void 0 : JSON.stringify(body);
|
|
11006
|
-
return new Promise((resolve10) => {
|
|
11007
|
-
let settled = false;
|
|
11008
|
-
const done = (result) => {
|
|
11009
|
-
if (settled) return;
|
|
11010
|
-
settled = true;
|
|
11011
|
-
clearTimeout(connectTimer);
|
|
11012
|
-
clearTimeout(responseTimer);
|
|
11013
|
-
resolve10(result);
|
|
11014
|
-
};
|
|
11015
|
-
const headers = { Authorization: `Bearer ${token}`, Accept: "application/json" };
|
|
11016
|
-
if (payload !== void 0) {
|
|
11017
|
-
headers["Content-Type"] = "application/json";
|
|
11018
|
-
headers["Content-Length"] = Buffer.byteLength(payload);
|
|
11019
|
-
}
|
|
11020
|
-
const req = request({ host: LOOPBACK_HOST, port, method, path: path5, headers, agent: false }, (res) => {
|
|
11021
|
-
const chunks = [];
|
|
11022
|
-
res.on("data", (c) => chunks.push(c));
|
|
11023
|
-
res.on("error", () => done({ kind: "unreachable" }));
|
|
11024
|
-
res.on("end", () => {
|
|
11025
|
-
const status = res.statusCode ?? 0;
|
|
11026
|
-
const text2 = Buffer.concat(chunks).toString("utf8");
|
|
11027
|
-
let parsed = null;
|
|
11028
|
-
try {
|
|
11029
|
-
parsed = text2.length > 0 ? JSON.parse(text2) : null;
|
|
11030
|
-
} catch {
|
|
11031
|
-
parsed = null;
|
|
11032
|
-
}
|
|
11033
|
-
if (status >= 200 && status < 300 && parsed !== null) done({ kind: "ok", status, body: parsed });
|
|
11034
|
-
else if (status === 401 || status === 400 || status === 404 || status === 413) done({ kind: "refused", status, ...errorFields(parsed) });
|
|
11035
|
-
else done({ kind: "unreachable" });
|
|
11036
|
-
});
|
|
11037
|
-
});
|
|
11038
|
-
const connectTimer = setTimeout(() => {
|
|
11039
|
-
req.destroy();
|
|
11040
|
-
done({ kind: "unreachable" });
|
|
11041
|
-
}, connectTimeout);
|
|
11042
|
-
const responseTimer = setTimeout(() => {
|
|
11043
|
-
req.destroy();
|
|
11044
|
-
done({ kind: "unreachable" });
|
|
11045
|
-
}, connectTimeout + responseTimeout);
|
|
11046
|
-
req.on("socket", (socket) => {
|
|
11047
|
-
socket.once("connect", () => clearTimeout(connectTimer));
|
|
11048
|
-
});
|
|
11049
|
-
req.on("error", () => done({ kind: "unreachable" }));
|
|
11050
|
-
if (payload !== void 0) req.write(payload);
|
|
11051
|
-
req.end();
|
|
11052
|
-
});
|
|
11156
|
+
function mintSessionRef() {
|
|
11157
|
+
return mintRef("session");
|
|
11053
11158
|
}
|
|
11054
|
-
|
|
11055
|
-
|
|
11056
|
-
if (
|
|
11057
|
-
|
|
11159
|
+
function parseRef(text2, expected) {
|
|
11160
|
+
if (typeof text2 !== "string") return { ok: false, reason: "a reference must be a string" };
|
|
11161
|
+
if (text2.length === 0) return { ok: false, reason: "a reference must not be empty" };
|
|
11162
|
+
if (text2 !== text2.trim()) return { ok: false, reason: "a reference must not carry surrounding whitespace" };
|
|
11163
|
+
if (text2 !== text2.toLowerCase()) return { ok: false, reason: "a reference must be lowercase" };
|
|
11164
|
+
const parts = text2.split(":");
|
|
11165
|
+
if (parts.length !== 3 || parts[0] !== "arcane") {
|
|
11166
|
+
return { ok: false, reason: 'a reference must have the form "arcane:<type>:<uuid>"' };
|
|
11167
|
+
}
|
|
11168
|
+
const type = parts[1];
|
|
11169
|
+
const uuid = parts[2];
|
|
11170
|
+
if (!REF_TYPES.includes(type)) {
|
|
11171
|
+
return { ok: false, reason: `"${type}" is not a registered reference type` };
|
|
11172
|
+
}
|
|
11173
|
+
if (uuid.startsWith("{") || uuid.endsWith("}")) {
|
|
11174
|
+
return { ok: false, reason: "a reference's uuid must not be braced" };
|
|
11175
|
+
}
|
|
11176
|
+
if (!ANY_UUID.test(uuid)) return { ok: false, reason: "a reference's uuid must be in canonical hex form" };
|
|
11177
|
+
if (!UUID_V7.test(uuid)) return { ok: false, reason: "a reference's uuid must be a version 7 UUID" };
|
|
11178
|
+
if (expected !== void 0 && type !== expected) {
|
|
11179
|
+
return { ok: false, reason: `expected a ${expected} reference, got one of type ${type}` };
|
|
11180
|
+
}
|
|
11181
|
+
return { ok: true, type, uuid };
|
|
11058
11182
|
}
|
|
11059
11183
|
|
|
11060
11184
|
// src/modules/presence/mcp.ts
|
|
@@ -11220,9 +11344,11 @@ var SERVER_NAME = "arcane-presence";
|
|
|
11220
11344
|
var NOT_CONFIGURED_TEXT = "no presence channel is configured on this machine";
|
|
11221
11345
|
var VERSION_KEY = "io.modelcontextprotocol/protocolVersion";
|
|
11222
11346
|
var SERVER_INFO_KEY = "io.modelcontextprotocol/serverInfo";
|
|
11347
|
+
var SUBSCRIPTION_KEY = "io.modelcontextprotocol/subscriptionId";
|
|
11348
|
+
var FEED_RETRY_MS = 5e3;
|
|
11223
11349
|
var SESSIONS_URI = "arcane://presence/sessions";
|
|
11224
11350
|
var CAPABILITIES_URI = "arcane://presence/capabilities";
|
|
11225
|
-
var SERVER_CAPABILITIES = { tools: {}, resources: {} };
|
|
11351
|
+
var SERVER_CAPABILITIES = { tools: {}, resources: { subscribe: true } };
|
|
11226
11352
|
var LINE = { type: "string", maxLength: 256 };
|
|
11227
11353
|
var PRESENCE_TOOLS = [
|
|
11228
11354
|
{
|
|
@@ -11321,9 +11447,132 @@ function serveMcp(options) {
|
|
|
11321
11447
|
if (outcome.kind === "silent") return text(`No session was open for ${where}.`);
|
|
11322
11448
|
return { ...text(`Ended ${outcome.record.session}.`), structuredContent: outcome.record };
|
|
11323
11449
|
};
|
|
11324
|
-
const
|
|
11450
|
+
const findResource = (uri) => {
|
|
11325
11451
|
const resource = PRESENCE_RESOURCES.find((r) => r.uri === uri);
|
|
11326
11452
|
if (resource === void 0) throw new RpcError(-32602, "Resource not found", { uri });
|
|
11453
|
+
return resource;
|
|
11454
|
+
};
|
|
11455
|
+
const legacy = /* @__PURE__ */ new Set();
|
|
11456
|
+
const listens = /* @__PURE__ */ new Map();
|
|
11457
|
+
let pending = 0;
|
|
11458
|
+
let feed = null;
|
|
11459
|
+
let connecting = null;
|
|
11460
|
+
let retry;
|
|
11461
|
+
let down = false;
|
|
11462
|
+
let closed2 = false;
|
|
11463
|
+
const retryMs = options.feedRetryMs ?? FEED_RETRY_MS;
|
|
11464
|
+
const watchesSessions = () => pending > 0 || legacy.has(SESSIONS_URI) || [...listens.values()].some((uris) => uris.includes(SESSIONS_URI));
|
|
11465
|
+
const notifyUpdated = () => {
|
|
11466
|
+
if (legacy.has(SESSIONS_URI)) write({ jsonrpc: "2.0", method: "notifications/resources/updated", params: { uri: SESSIONS_URI } });
|
|
11467
|
+
for (const [id, uris] of listens) {
|
|
11468
|
+
if (uris.includes(SESSIONS_URI)) write({ jsonrpc: "2.0", method: "notifications/resources/updated", params: { _meta: { [SUBSCRIPTION_KEY]: id }, uri: SESSIONS_URI } });
|
|
11469
|
+
}
|
|
11470
|
+
};
|
|
11471
|
+
const releaseFeedIfIdle = () => {
|
|
11472
|
+
if (watchesSessions()) return;
|
|
11473
|
+
feed?.close();
|
|
11474
|
+
feed = null;
|
|
11475
|
+
clearTimeout(retry);
|
|
11476
|
+
retry = void 0;
|
|
11477
|
+
down = false;
|
|
11478
|
+
};
|
|
11479
|
+
const scheduleRetry = () => {
|
|
11480
|
+
if (retry !== void 0 || closed2 || !watchesSessions()) return;
|
|
11481
|
+
retry = setTimeout(() => {
|
|
11482
|
+
retry = void 0;
|
|
11483
|
+
void ensureFeed();
|
|
11484
|
+
}, retryMs);
|
|
11485
|
+
retry.unref();
|
|
11486
|
+
};
|
|
11487
|
+
const ensureFeed = () => {
|
|
11488
|
+
if (closed2 || feed !== null || !watchesSessions()) return Promise.resolve();
|
|
11489
|
+
if (connecting !== null) return connecting;
|
|
11490
|
+
connecting = openEventFeed(
|
|
11491
|
+
{
|
|
11492
|
+
onEvent: (name) => {
|
|
11493
|
+
if (SESSION_EVENTS.includes(name)) notifyUpdated();
|
|
11494
|
+
},
|
|
11495
|
+
onClose: () => {
|
|
11496
|
+
feed = null;
|
|
11497
|
+
if (closed2 || !watchesSessions()) return;
|
|
11498
|
+
down = true;
|
|
11499
|
+
notifyUpdated();
|
|
11500
|
+
scheduleRetry();
|
|
11501
|
+
}
|
|
11502
|
+
},
|
|
11503
|
+
deps
|
|
11504
|
+
).then((opened) => {
|
|
11505
|
+
connecting = null;
|
|
11506
|
+
if (opened === null) {
|
|
11507
|
+
down = true;
|
|
11508
|
+
scheduleRetry();
|
|
11509
|
+
return;
|
|
11510
|
+
}
|
|
11511
|
+
if (closed2 || !watchesSessions()) {
|
|
11512
|
+
opened.close();
|
|
11513
|
+
return;
|
|
11514
|
+
}
|
|
11515
|
+
feed = opened;
|
|
11516
|
+
if (down) {
|
|
11517
|
+
down = false;
|
|
11518
|
+
notifyUpdated();
|
|
11519
|
+
}
|
|
11520
|
+
});
|
|
11521
|
+
return connecting;
|
|
11522
|
+
};
|
|
11523
|
+
const subscribe = async (uri) => {
|
|
11524
|
+
const resource = findResource(uri);
|
|
11525
|
+
if (resource.uri === SESSIONS_URI) {
|
|
11526
|
+
pending++;
|
|
11527
|
+
await ensureFeed();
|
|
11528
|
+
pending--;
|
|
11529
|
+
}
|
|
11530
|
+
legacy.add(resource.uri);
|
|
11531
|
+
return {};
|
|
11532
|
+
};
|
|
11533
|
+
const unsubscribe = (uri) => {
|
|
11534
|
+
legacy.delete(findResource(uri).uri);
|
|
11535
|
+
releaseFeedIfIdle();
|
|
11536
|
+
return {};
|
|
11537
|
+
};
|
|
11538
|
+
const listen = async (id, params) => {
|
|
11539
|
+
if (listens.has(id)) throw new RpcError(-32602, "A subscription with this id is already open", { id });
|
|
11540
|
+
const filter = isObject(params["notifications"]) ? params["notifications"] : {};
|
|
11541
|
+
const asked = filter["resourceSubscriptions"];
|
|
11542
|
+
if (asked !== void 0 && (!Array.isArray(asked) || asked.some((u) => typeof u !== "string"))) {
|
|
11543
|
+
throw new RpcError(-32602, "notifications.resourceSubscriptions must be a list of URIs");
|
|
11544
|
+
}
|
|
11545
|
+
const honored = Array.isArray(asked) && asked.includes(SESSIONS_URI) ? [SESSIONS_URI] : [];
|
|
11546
|
+
if (honored.length > 0) {
|
|
11547
|
+
pending++;
|
|
11548
|
+
await ensureFeed();
|
|
11549
|
+
pending--;
|
|
11550
|
+
}
|
|
11551
|
+
write({
|
|
11552
|
+
jsonrpc: "2.0",
|
|
11553
|
+
method: "notifications/subscriptions/acknowledged",
|
|
11554
|
+
params: { _meta: { [SUBSCRIPTION_KEY]: id }, notifications: honored.length > 0 ? { resourceSubscriptions: honored } : {} }
|
|
11555
|
+
});
|
|
11556
|
+
listens.set(id, honored);
|
|
11557
|
+
};
|
|
11558
|
+
const cancel = (params) => {
|
|
11559
|
+
const requestId = params["requestId"];
|
|
11560
|
+
if ((typeof requestId === "string" || typeof requestId === "number") && listens.delete(requestId)) releaseFeedIfIdle();
|
|
11561
|
+
};
|
|
11562
|
+
const teardown = () => {
|
|
11563
|
+
closed2 = true;
|
|
11564
|
+
for (const id of listens.keys()) {
|
|
11565
|
+
write({ jsonrpc: "2.0", id, result: { resultType: "complete", _meta: { [SUBSCRIPTION_KEY]: id } } });
|
|
11566
|
+
write({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: id, reason: "The server's input closed." } });
|
|
11567
|
+
}
|
|
11568
|
+
listens.clear();
|
|
11569
|
+
legacy.clear();
|
|
11570
|
+
feed?.close();
|
|
11571
|
+
feed = null;
|
|
11572
|
+
clearTimeout(retry);
|
|
11573
|
+
};
|
|
11574
|
+
const read = async (uri) => {
|
|
11575
|
+
const resource = findResource(uri);
|
|
11327
11576
|
const result = await callPresence("GET", resource.uri === SESSIONS_URI ? "/v0/sessions" : "/v0/health", void 0, deps);
|
|
11328
11577
|
if (result.kind !== "ok") return { contents: [{ uri: resource.uri, mimeType: "text/plain", text: NOT_CONFIGURED_TEXT }] };
|
|
11329
11578
|
const body = resource.uri === SESSIONS_URI ? result.body : result.body.capabilities;
|
|
@@ -11351,6 +11600,10 @@ function serveMcp(options) {
|
|
|
11351
11600
|
return { resources: PRESENCE_RESOURCES };
|
|
11352
11601
|
case "resources/read":
|
|
11353
11602
|
return read(params["uri"]);
|
|
11603
|
+
case "resources/subscribe":
|
|
11604
|
+
return subscribe(params["uri"]);
|
|
11605
|
+
case "resources/unsubscribe":
|
|
11606
|
+
return unsubscribe(params["uri"]);
|
|
11354
11607
|
default:
|
|
11355
11608
|
throw new RpcError(-32601, `Method not found: ${method}`);
|
|
11356
11609
|
}
|
|
@@ -11377,7 +11630,10 @@ function serveMcp(options) {
|
|
|
11377
11630
|
}
|
|
11378
11631
|
const id = rawId ?? null;
|
|
11379
11632
|
const params = isObject(message["params"]) ? message["params"] : {};
|
|
11380
|
-
if (!hasId)
|
|
11633
|
+
if (!hasId) {
|
|
11634
|
+
if (method === "notifications/cancelled") cancel(params);
|
|
11635
|
+
return;
|
|
11636
|
+
}
|
|
11381
11637
|
try {
|
|
11382
11638
|
if (method === "initialize") {
|
|
11383
11639
|
const requested2 = params["protocolVersion"];
|
|
@@ -11396,6 +11652,13 @@ function serveMcp(options) {
|
|
|
11396
11652
|
reply(id, { resultType: "complete", supportedVersions: SUPPORTED_VERSIONS, capabilities: SERVER_CAPABILITIES, _meta: { [SERVER_INFO_KEY]: { name: SERVER_NAME, version: options.version } } });
|
|
11397
11653
|
return;
|
|
11398
11654
|
}
|
|
11655
|
+
if (method === "subscriptions/listen") {
|
|
11656
|
+
if (!modern) throw new RpcError(-32601, `Method not found: ${method}`);
|
|
11657
|
+
if (id === null) throw new RpcError(-32600, "subscriptions/listen needs a request id");
|
|
11658
|
+
await listen(id, params);
|
|
11659
|
+
return;
|
|
11660
|
+
}
|
|
11661
|
+
if (modern && (method === "resources/subscribe" || method === "resources/unsubscribe")) throw new RpcError(-32601, `Method not found: ${method}`);
|
|
11399
11662
|
const result = await handle(method, params);
|
|
11400
11663
|
reply(id, modern ? { resultType: "complete", ...result } : result);
|
|
11401
11664
|
} catch (error2) {
|
|
@@ -11415,7 +11678,10 @@ function serveMcp(options) {
|
|
|
11415
11678
|
chain = chain.then(() => dispatch(line2));
|
|
11416
11679
|
});
|
|
11417
11680
|
lines.on("close", () => {
|
|
11418
|
-
void chain.then(() =>
|
|
11681
|
+
void chain.then(() => {
|
|
11682
|
+
teardown();
|
|
11683
|
+
resolve10();
|
|
11684
|
+
});
|
|
11419
11685
|
});
|
|
11420
11686
|
});
|
|
11421
11687
|
}
|
|
@@ -12062,6 +12328,12 @@ function isHealth(body) {
|
|
|
12062
12328
|
const b = body;
|
|
12063
12329
|
return b !== null && typeof b === "object" && b.ok === true && b.contract === "v0" && typeof b.started_at === "string";
|
|
12064
12330
|
}
|
|
12331
|
+
function unavailableLine(info, result) {
|
|
12332
|
+
if (result.kind === "refused" && result.status === 401) {
|
|
12333
|
+
return `The presence service at ${info.url} refused this machine's token (rotated, or ${PRESENCE_ENV.token} is set to another value).`;
|
|
12334
|
+
}
|
|
12335
|
+
return `The presence service recorded at ${info.url} is not reachable.`;
|
|
12336
|
+
}
|
|
12065
12337
|
async function runPresenceStatus(io = consoleIo, store = {}) {
|
|
12066
12338
|
const info = readServerInfo(store);
|
|
12067
12339
|
if (info === null) {
|
|
@@ -12076,12 +12348,59 @@ async function runPresenceStatus(io = consoleIo, store = {}) {
|
|
|
12076
12348
|
io.out(` sessions: ${result.body.sessions}`);
|
|
12077
12349
|
return 0;
|
|
12078
12350
|
}
|
|
12079
|
-
|
|
12080
|
-
|
|
12351
|
+
io.out(unavailableLine(info, result));
|
|
12352
|
+
return 1;
|
|
12353
|
+
}
|
|
12354
|
+
function isSnapshot(body) {
|
|
12355
|
+
const b = body;
|
|
12356
|
+
return b !== null && typeof b === "object" && Array.isArray(b.sessions) && typeof b.generated_at === "string";
|
|
12357
|
+
}
|
|
12358
|
+
function formatAge(fromIso, now) {
|
|
12359
|
+
const seconds = Math.max(0, Math.floor((now - Date.parse(fromIso)) / 1e3));
|
|
12360
|
+
if (!Number.isFinite(seconds)) return "-";
|
|
12361
|
+
if (seconds < 60) return `${seconds}s`;
|
|
12362
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
|
12363
|
+
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
|
|
12364
|
+
return `${Math.floor(seconds / 86400)}d`;
|
|
12365
|
+
}
|
|
12366
|
+
function renderSessionsTable(sessions, now) {
|
|
12367
|
+
if (sessions.length === 0) return ["No session is reporting."];
|
|
12368
|
+
const header = ["STATUS", "AGENT", "HARNESS", "REPOSITORY", "BRANCH", "AGE", "TASK"];
|
|
12369
|
+
const rows = sessions.map((s) => [
|
|
12370
|
+
s.stale ? "unknown" : s.status,
|
|
12371
|
+
s.agent.slug ?? "-",
|
|
12372
|
+
s.harness,
|
|
12373
|
+
s.repository.name,
|
|
12374
|
+
s.checkout.branch ?? "-",
|
|
12375
|
+
formatAge(s.reported_at, now),
|
|
12376
|
+
s.task ?? "-"
|
|
12377
|
+
]);
|
|
12378
|
+
const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
|
|
12379
|
+
const line2 = (cells) => cells.map((c, i) => i === cells.length - 1 ? c : c.padEnd(widths[i])).join(" ").trimEnd();
|
|
12380
|
+
const out = [line2(header)];
|
|
12381
|
+
sessions.forEach((s, i) => {
|
|
12382
|
+
out.push(line2(rows[i]));
|
|
12383
|
+
for (const n of s.needs_you) out.push(` needs you: ${n.reason === null ? n.action : `${n.action} \u2014 ${n.reason}`}`);
|
|
12384
|
+
});
|
|
12385
|
+
return out;
|
|
12386
|
+
}
|
|
12387
|
+
async function runPresenceSessions(options, io = consoleIo, store = {}, now = Date.now) {
|
|
12388
|
+
const info = readServerInfo(store);
|
|
12389
|
+
if (info === null) {
|
|
12390
|
+
io.out(NOT_CONFIGURED);
|
|
12081
12391
|
return 1;
|
|
12082
12392
|
}
|
|
12083
|
-
|
|
12084
|
-
|
|
12393
|
+
const result = await callPresence("GET", "/v0/sessions", void 0, store);
|
|
12394
|
+
if (result.kind !== "ok" || !isSnapshot(result.body)) {
|
|
12395
|
+
io.out(unavailableLine(info, result));
|
|
12396
|
+
return 1;
|
|
12397
|
+
}
|
|
12398
|
+
if (options.json === true) {
|
|
12399
|
+
io.out(JSON.stringify(result.body, null, 2));
|
|
12400
|
+
return 0;
|
|
12401
|
+
}
|
|
12402
|
+
for (const line2 of renderSessionsTable(result.body.sessions, now())) io.out(line2);
|
|
12403
|
+
return 0;
|
|
12085
12404
|
}
|
|
12086
12405
|
async function waitForExit(pid, ms) {
|
|
12087
12406
|
const until = Date.now() + ms;
|
|
@@ -12294,7 +12613,7 @@ program.command("logout").description("Sign out on this machine: remove the stor
|
|
|
12294
12613
|
program.command("whoami").description("Show who is signed in; exits 1 when signed out").option("--verify", "Also check the session with the identity service").action(async (opts) => {
|
|
12295
12614
|
await runWhoami({ verify: opts.verify });
|
|
12296
12615
|
});
|
|
12297
|
-
program.command("doctor").description("Check your environment for Arcane prerequisites").option("--fix", "
|
|
12616
|
+
program.command("doctor").description("Check your environment for Arcane prerequisites").option("--fix", "Create missing session continuity files, and remove a presence record whose service is gone").option("--leaks", "Run only the on-demand secrets/credential scan (ARC-037), skipping the default checks").action(async (opts) => {
|
|
12298
12617
|
await runDoctor(
|
|
12299
12618
|
process.cwd(),
|
|
12300
12619
|
{ fix: opts.fix, leaks: opts.leaks, packageVersion: packageJson.version },
|
|
@@ -12376,6 +12695,9 @@ presenceCommand.command("serve").description("Run the presence service in the fo
|
|
|
12376
12695
|
presenceCommand.command("status").description("Say whether a presence service is running and answering this machine's token (exit 0) or not (exit 1)").action(async () => {
|
|
12377
12696
|
process.exitCode = await runPresenceStatus();
|
|
12378
12697
|
});
|
|
12698
|
+
presenceCommand.command("sessions").description("List the sessions the presence service holds: a table, or the GET /v0/sessions document with --json. Exit 1 when no channel answers").option("--json", "Print the snapshot document as JSON instead of the table").action(async (opts) => {
|
|
12699
|
+
process.exitCode = await runPresenceSessions(opts);
|
|
12700
|
+
});
|
|
12379
12701
|
presenceCommand.command("stop").description("Stop the presence service this machine recorded; exits 0 whether or not one was running").action(async () => {
|
|
12380
12702
|
process.exitCode = await runPresenceStop();
|
|
12381
12703
|
});
|