arcane-cli 1.11.3 → 1.11.5
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/dist/assets/.arcane/agents/agent-policies-template.md +1 -2
- package/dist/assets/.arcane/governance/agent-policies.md +2 -3
- package/dist/assets/.arcane/governance/git-conventions.md +20 -35
- package/dist/assets/.arcane/governance/spell-authoring-standards.md +4 -1
- package/dist/assets/.arcane/governance/universal-agent-rules.md +1 -1
- package/dist/assets/.arcane/image-prompts/adelaide-image-prompt.md +1 -1
- package/dist/assets/.arcane/image-prompts/alexander-image-prompt.md +1 -1
- package/dist/assets/.arcane/image-prompts/bess-image-prompt.md +1 -1
- package/dist/assets/.arcane/image-prompts/circe-image-prompt.md +1 -1
- package/dist/assets/.arcane/image-prompts/custodio-image-prompt.md +1 -1
- package/dist/assets/.arcane/image-prompts/iris-image-prompt.md +1 -1
- package/dist/assets/.arcane/image-prompts/kellar-image-prompt.md +1 -1
- package/dist/assets/.arcane/image-prompts/lafayette-image-prompt.md +1 -1
- package/dist/assets/.arcane/image-prompts/lince-image-prompt.md +1 -1
- package/dist/assets/.arcane/image-prompts/mercurio-image-prompt.md +1 -1
- package/dist/assets/.arcane/image-prompts/merlin-image-prompt.md +1 -1
- package/dist/assets/.arcane/image-prompts/prospero-image-prompt.md +1 -1
- package/dist/assets/.arcane/spells/_fragments/branch-naming.md +9 -0
- package/dist/assets/.arcane/spells/_fragments/branch-rename-gate.md +7 -0
- package/dist/assets/.arcane/spells/spell-architect.md +1 -1
- package/dist/assets/.arcane/spells/spell-commit-work.md +1 -1
- package/dist/assets/.arcane/spells/spell-create-pull-request.md +21 -0
- package/dist/assets/.arcane/spells/spell-full-cycle.md +1 -1
- package/dist/assets/.arcane/spells/spell-open-session.md +29 -7
- package/dist/assets/.github/instructions/agent-output.instructions.md +10 -13
- package/dist/index.js +815 -68
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
package/dist/index.js
CHANGED
|
@@ -4,9 +4,9 @@ const require = __createRequire(import.meta.url);
|
|
|
4
4
|
|
|
5
5
|
// src/index.ts
|
|
6
6
|
import { Command } from "commander";
|
|
7
|
-
import { createRequire as
|
|
7
|
+
import { createRequire as createRequire3 } from "module";
|
|
8
8
|
import { fileURLToPath } from "url";
|
|
9
|
-
import { dirname as
|
|
9
|
+
import { dirname as dirname12, join as join21 } from "path";
|
|
10
10
|
|
|
11
11
|
// src/commands/init.ts
|
|
12
12
|
import { select as select3, confirm as confirm2, input as input3 } from "@inquirer/prompts";
|
|
@@ -1214,15 +1214,15 @@ async function syncUserTierFanout(options) {
|
|
|
1214
1214
|
const rendered = await renderUserFanout(options.storeRoot, options.spellIds, options.fallbackDir);
|
|
1215
1215
|
const desired = [...rendered.files, ...options.extraFiles ?? []];
|
|
1216
1216
|
const owned = options.ownedClients;
|
|
1217
|
-
const ownsClient = (
|
|
1217
|
+
const ownsClient = (client2) => owned === void 0 || client2 !== void 0 && owned.includes(client2);
|
|
1218
1218
|
const previous = options.previous ?? {};
|
|
1219
1219
|
const dryRun = Boolean(options.dryRun);
|
|
1220
1220
|
const record = {};
|
|
1221
1221
|
const outcomes = [];
|
|
1222
1222
|
const desiredPaths = new Set(desired.map((f) => f.relativePath));
|
|
1223
1223
|
for (const id of rendered.missing) {
|
|
1224
|
-
for (const
|
|
1225
|
-
const relativePath = USER_FANOUT_PATHS[
|
|
1224
|
+
for (const client2 of SPELL_FANOUT_CLIENTS) {
|
|
1225
|
+
const relativePath = USER_FANOUT_PATHS[client2](id);
|
|
1226
1226
|
desiredPaths.add(relativePath);
|
|
1227
1227
|
if (previous[relativePath] !== void 0) record[relativePath] = previous[relativePath];
|
|
1228
1228
|
outcomes.push({ relativePath, status: "unrenderable" });
|
|
@@ -1311,15 +1311,15 @@ function describeFanoutOutcomes(outcomes, dryRun = false) {
|
|
|
1311
1311
|
const n = { codex: 0, claude: 0, "copilot-agents": 0 };
|
|
1312
1312
|
for (const o of outcomes) {
|
|
1313
1313
|
if (o.status !== status) continue;
|
|
1314
|
-
const
|
|
1315
|
-
if (
|
|
1314
|
+
const client2 = clientOfFanoutPath(o.relativePath);
|
|
1315
|
+
if (client2 !== void 0) n[client2]++;
|
|
1316
1316
|
}
|
|
1317
1317
|
return n;
|
|
1318
1318
|
};
|
|
1319
1319
|
const paths = (status) => outcomes.filter((o) => o.status === status).map((o) => ` ~/${o.relativePath}`).join("\n");
|
|
1320
1320
|
if (counts.written > 0) {
|
|
1321
1321
|
const n = byClient("written");
|
|
1322
|
-
const breakdown = Object.keys(n).filter((
|
|
1322
|
+
const breakdown = Object.keys(n).filter((client2) => n[client2] > 0).map((client2) => `${n[client2]} ${FANOUT_CLIENT_LABELS[client2]}`).join(", ");
|
|
1323
1323
|
lines.push(
|
|
1324
1324
|
`${prefix}${dryRun ? "write" : "Wrote"} ${counts.written} client file(s): ${breakdown}.`
|
|
1325
1325
|
);
|
|
@@ -1365,8 +1365,8 @@ async function inspectUserTierFanout(homeDir, record) {
|
|
|
1365
1365
|
};
|
|
1366
1366
|
for (const [relativePath, recorded] of Object.entries(record ?? {})) {
|
|
1367
1367
|
health.total++;
|
|
1368
|
-
const
|
|
1369
|
-
if (
|
|
1368
|
+
const client2 = clientOfFanoutPath(relativePath);
|
|
1369
|
+
if (client2 !== void 0) health.byClient[client2]++;
|
|
1370
1370
|
const target = join(homeDir, relativePath);
|
|
1371
1371
|
const state = await classifyTarget(target);
|
|
1372
1372
|
if (state === "absent") {
|
|
@@ -1830,12 +1830,12 @@ function requireEnum(value, allowed, path4, issues) {
|
|
|
1830
1830
|
}
|
|
1831
1831
|
function validateOptionalClient(clients, key, property, expected, issues) {
|
|
1832
1832
|
if (clients[key] === void 0) return;
|
|
1833
|
-
const
|
|
1834
|
-
if (!
|
|
1833
|
+
const client2 = requireRecord(clients[key], `clients.${key}`, issues);
|
|
1834
|
+
if (!client2) return;
|
|
1835
1835
|
if (expected === "string") {
|
|
1836
|
-
requireString(
|
|
1836
|
+
requireString(client2[property], `clients.${key}.${property}`, issues);
|
|
1837
1837
|
} else {
|
|
1838
|
-
requireBoolean(
|
|
1838
|
+
requireBoolean(client2[property], `clients.${key}.${property}`, issues);
|
|
1839
1839
|
}
|
|
1840
1840
|
}
|
|
1841
1841
|
function validateAgentDefinition(value, source) {
|
|
@@ -2002,7 +2002,7 @@ function projectAgentsDir(targetDir, scope = "repo") {
|
|
|
2002
2002
|
}
|
|
2003
2003
|
|
|
2004
2004
|
// src/modules/agent-generator.ts
|
|
2005
|
-
import { writeFile as writeFile5, mkdir as mkdir3, readdir as readdir4 } from "fs/promises";
|
|
2005
|
+
import { writeFile as writeFile5, mkdir as mkdir3, readdir as readdir4, readFile as readFile6 } from "fs/promises";
|
|
2006
2006
|
import { join as join3 } from "path";
|
|
2007
2007
|
import { homedir as homedir2 } from "os";
|
|
2008
2008
|
|
|
@@ -2208,6 +2208,25 @@ function renderSpellRoutingSection() {
|
|
|
2208
2208
|
| Review code or a PR | \`spell-review\` |
|
|
2209
2209
|
|
|
2210
2210
|
If a spell exists for the workflow you are about to perform, invoke it \u2014 do not improvise the workflow from general knowledge, even when the user doesn't name the spell.
|
|
2211
|
+
`;
|
|
2212
|
+
}
|
|
2213
|
+
async function loadBranchNamingFragment(assetsDir) {
|
|
2214
|
+
try {
|
|
2215
|
+
const fragment = await readFile6(
|
|
2216
|
+
join3(assetsDir, ".arcane", "spells", "_fragments", "branch-naming.md"),
|
|
2217
|
+
"utf8"
|
|
2218
|
+
);
|
|
2219
|
+
return fragment.trim();
|
|
2220
|
+
} catch {
|
|
2221
|
+
return null;
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
function renderBranchNamingSection(fragment) {
|
|
2225
|
+
if (fragment === null) return "";
|
|
2226
|
+
return `## Branch Naming
|
|
2227
|
+
|
|
2228
|
+
${fragment}
|
|
2229
|
+
|
|
2211
2230
|
`;
|
|
2212
2231
|
}
|
|
2213
2232
|
function renderAgentRosterSection(entries) {
|
|
@@ -2233,6 +2252,7 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
|
|
|
2233
2252
|
const synced = [];
|
|
2234
2253
|
const skipped = [];
|
|
2235
2254
|
let hasUnresolvedRoles = false;
|
|
2255
|
+
const branchNaming = renderBranchNamingSection(await loadBranchNamingFragment(assetsDir));
|
|
2236
2256
|
const userTier = options.scope === "user";
|
|
2237
2257
|
const repoOptedOut = !userTier && options.spellScope === "user";
|
|
2238
2258
|
const userAgentFiles = [];
|
|
@@ -2351,7 +2371,7 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
|
|
|
2351
2371
|
}
|
|
2352
2372
|
if (!userTier) {
|
|
2353
2373
|
const copilotSection = `${renderSpellRoutingSection()}
|
|
2354
|
-
${renderAgentRosterSection(resolved)}`;
|
|
2374
|
+
${branchNaming}${renderAgentRosterSection(resolved)}`;
|
|
2355
2375
|
const copilotMerged = await mergeIntoFile(
|
|
2356
2376
|
targetDir,
|
|
2357
2377
|
".github/copilot-instructions.md",
|
|
@@ -2367,7 +2387,7 @@ ${renderAgentRosterSection(resolved)}`;
|
|
|
2367
2387
|
}
|
|
2368
2388
|
if (options.claude !== false) {
|
|
2369
2389
|
const claudeSection = `${renderSpellRoutingSection()}
|
|
2370
|
-
${renderAgentRosterSection(resolved)}`;
|
|
2390
|
+
${branchNaming}${renderAgentRosterSection(resolved)}`;
|
|
2371
2391
|
const claudeMerged = await mergeIntoFile(
|
|
2372
2392
|
targetDir,
|
|
2373
2393
|
"CLAUDE.md",
|
|
@@ -2378,7 +2398,7 @@ ${renderAgentRosterSection(resolved)}`;
|
|
|
2378
2398
|
}
|
|
2379
2399
|
if (options.codex !== false) {
|
|
2380
2400
|
const codexSection = `${renderSpellRoutingSection()}
|
|
2381
|
-
${renderAgentRosterSection(resolved)}`;
|
|
2401
|
+
${branchNaming}${renderAgentRosterSection(resolved)}`;
|
|
2382
2402
|
const codexMerged = await mergeIntoFile(
|
|
2383
2403
|
targetDir,
|
|
2384
2404
|
"AGENTS.md",
|
|
@@ -3314,7 +3334,7 @@ async function offerRegistryScaffold(targetDir, businessRoot) {
|
|
|
3314
3334
|
}
|
|
3315
3335
|
|
|
3316
3336
|
// src/modules/push-safety.ts
|
|
3317
|
-
import { mkdir as mkdir5, readFile as
|
|
3337
|
+
import { mkdir as mkdir5, readFile as readFile7, writeFile as writeFile8, chmod, access as access3, rm as rm4, readdir as readdir6 } from "fs/promises";
|
|
3318
3338
|
import { constants } from "fs";
|
|
3319
3339
|
import { join as join6, resolve as resolve2, dirname as dirname3, basename } from "path";
|
|
3320
3340
|
var DISABLED_PUSH_URL = "arcane-push-blocked-run-spell-unblock-push://this-repository";
|
|
@@ -3492,7 +3512,7 @@ async function hookFileMatches(cwd, hookName, body) {
|
|
|
3492
3512
|
const hookPath = await hookFilePath(cwd, hookName);
|
|
3493
3513
|
if (!await fileExists(hookPath)) return false;
|
|
3494
3514
|
try {
|
|
3495
|
-
if (await
|
|
3515
|
+
if (await readFile7(hookPath, "utf-8") !== body) return false;
|
|
3496
3516
|
} catch {
|
|
3497
3517
|
return false;
|
|
3498
3518
|
}
|
|
@@ -4353,8 +4373,8 @@ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
|
|
|
4353
4373
|
for (const file of component.files) printDryRun(`Would copy: ${file}`);
|
|
4354
4374
|
}
|
|
4355
4375
|
for (const id of spellIds) {
|
|
4356
|
-
for (const
|
|
4357
|
-
const relativePath = USER_FANOUT_PATHS[
|
|
4376
|
+
for (const client2 of ["codex", "claude"]) {
|
|
4377
|
+
const relativePath = USER_FANOUT_PATHS[client2](id);
|
|
4358
4378
|
if (await fileExists(join7(homeDir, relativePath))) {
|
|
4359
4379
|
printDryRun(`Would leave alone (exists, not written by Arcane): ~/${relativePath}`);
|
|
4360
4380
|
} else {
|
|
@@ -4419,7 +4439,7 @@ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
|
|
|
4419
4439
|
}
|
|
4420
4440
|
|
|
4421
4441
|
// src/modules/component-install.ts
|
|
4422
|
-
import { readFile as
|
|
4442
|
+
import { readFile as readFile8, stat, writeFile as writeFile9 } from "fs/promises";
|
|
4423
4443
|
import { join as join8 } from "path";
|
|
4424
4444
|
async function planComponentInstall(component, targetDir, assetsDir, opts = {}) {
|
|
4425
4445
|
const files = [];
|
|
@@ -4454,7 +4474,7 @@ async function executeInstallPlan(plan, targetDir) {
|
|
|
4454
4474
|
const written = [];
|
|
4455
4475
|
try {
|
|
4456
4476
|
for (const { file, src, state } of plan.toCopy) {
|
|
4457
|
-
const previous = state === "missing" ? null : await
|
|
4477
|
+
const previous = state === "missing" ? null : await readFile8(join8(targetDir, file));
|
|
4458
4478
|
fileHashes[file] = await copyFile(src, targetDir, file, { force: true });
|
|
4459
4479
|
written.push({ file, previous });
|
|
4460
4480
|
files.push(file);
|
|
@@ -4609,7 +4629,7 @@ async function runAdd(names, options, targetDir, assetsDir, packageVersion) {
|
|
|
4609
4629
|
|
|
4610
4630
|
// src/commands/update.ts
|
|
4611
4631
|
import { dirname as dirname5, join as join10 } from "path";
|
|
4612
|
-
import { readFile as
|
|
4632
|
+
import { readFile as readFile9, writeFile as writeFile10 } from "fs/promises";
|
|
4613
4633
|
|
|
4614
4634
|
// src/modules/merge3.ts
|
|
4615
4635
|
var CONFLICT_START = "<<<<<<< yours (edited)";
|
|
@@ -5436,8 +5456,8 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
5436
5456
|
handled = true;
|
|
5437
5457
|
} else {
|
|
5438
5458
|
const [currentContent, newContent] = await Promise.all([
|
|
5439
|
-
|
|
5440
|
-
|
|
5459
|
+
readFile9(join10(targetDir, file), "utf-8"),
|
|
5460
|
+
readFile9(srcPath, "utf-8")
|
|
5441
5461
|
]);
|
|
5442
5462
|
const result = merge3(oldVendorContent, currentContent, newContent);
|
|
5443
5463
|
await writeFile10(join10(targetDir, file), result.content, "utf-8");
|
|
@@ -5625,7 +5645,7 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
|
|
|
5625
5645
|
import { dirname as dirname7 } from "path";
|
|
5626
5646
|
|
|
5627
5647
|
// src/modules/version-check.ts
|
|
5628
|
-
import { readFile as
|
|
5648
|
+
import { readFile as readFile10, writeFile as writeFile11, mkdir as mkdir6 } from "fs/promises";
|
|
5629
5649
|
import { dirname as dirname6, join as join11 } from "path";
|
|
5630
5650
|
import { homedir as homedir3 } from "os";
|
|
5631
5651
|
import { createRequire } from "module";
|
|
@@ -5644,7 +5664,7 @@ var DEFAULT_CACHE_PATH = join11(homedir3(), ".arcane", "version-cache.json");
|
|
|
5644
5664
|
var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
5645
5665
|
async function readCache(cachePath) {
|
|
5646
5666
|
try {
|
|
5647
|
-
const content = await
|
|
5667
|
+
const content = await readFile10(cachePath, "utf-8");
|
|
5648
5668
|
const parsed = JSON.parse(content);
|
|
5649
5669
|
if (typeof parsed.latest !== "string" || typeof parsed.checkedAt !== "string") {
|
|
5650
5670
|
return null;
|
|
@@ -6028,7 +6048,7 @@ async function uninstallUserTier(options, storeRoot, manifest) {
|
|
|
6028
6048
|
}
|
|
6029
6049
|
|
|
6030
6050
|
// src/commands/doctor.ts
|
|
6031
|
-
import { access as access4, readdir as readdir9, readFile as
|
|
6051
|
+
import { access as access4, readdir as readdir9, readFile as readFile13, mkdir as mkdir7, copyFile as fsCopyFile } from "fs/promises";
|
|
6032
6052
|
import { join as join15, dirname as dirname9 } from "path";
|
|
6033
6053
|
|
|
6034
6054
|
// src/config/incidents.ts
|
|
@@ -6097,7 +6117,7 @@ function evaluateIncidentGate(records) {
|
|
|
6097
6117
|
}
|
|
6098
6118
|
|
|
6099
6119
|
// src/modules/denylist-scan.ts
|
|
6100
|
-
import { readFile as
|
|
6120
|
+
import { readFile as readFile11, readdir as readdir7 } from "fs/promises";
|
|
6101
6121
|
import { join as join13, relative } from "path";
|
|
6102
6122
|
function escapeRegExp(value) {
|
|
6103
6123
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -6115,7 +6135,7 @@ async function scanFile(absolutePath, displayPath, rules) {
|
|
|
6115
6135
|
const findings = [];
|
|
6116
6136
|
let content;
|
|
6117
6137
|
try {
|
|
6118
|
-
content = await
|
|
6138
|
+
content = await readFile11(absolutePath, "utf8");
|
|
6119
6139
|
} catch {
|
|
6120
6140
|
return findings;
|
|
6121
6141
|
}
|
|
@@ -6409,7 +6429,7 @@ function parseAdoRemote(url) {
|
|
|
6409
6429
|
}
|
|
6410
6430
|
|
|
6411
6431
|
// src/modules/placeholders.ts
|
|
6412
|
-
import { readdir as readdir8, readFile as
|
|
6432
|
+
import { readdir as readdir8, readFile as readFile12 } from "fs/promises";
|
|
6413
6433
|
import { join as join14 } from "path";
|
|
6414
6434
|
var PLACEHOLDER_STANDARD_FILE = "spell-authoring-standards.md";
|
|
6415
6435
|
var RUNTIME_PLACEHOLDERS_START = "<!-- runtime-placeholders:start -->";
|
|
@@ -6505,7 +6525,7 @@ function parseRuntimePlaceholders(standardContent) {
|
|
|
6505
6525
|
async function scanGovernancePlaceholders(governanceDir) {
|
|
6506
6526
|
let standard;
|
|
6507
6527
|
try {
|
|
6508
|
-
standard = await
|
|
6528
|
+
standard = await readFile12(join14(governanceDir, PLACEHOLDER_STANDARD_FILE), "utf8");
|
|
6509
6529
|
} catch (err) {
|
|
6510
6530
|
if (err.code === "ENOENT") {
|
|
6511
6531
|
return { status: "skipped", reason: `${PLACEHOLDER_STANDARD_FILE} is not installed` };
|
|
@@ -6524,7 +6544,7 @@ async function scanGovernancePlaceholders(governanceDir) {
|
|
|
6524
6544
|
const templateDocs = [];
|
|
6525
6545
|
const findings = [];
|
|
6526
6546
|
for (const file of files) {
|
|
6527
|
-
const content = await
|
|
6547
|
+
const content = await readFile12(join14(governanceDir, file), "utf8");
|
|
6528
6548
|
const status = parseFrontmatterStatus(content);
|
|
6529
6549
|
if (status === "template") templateDocs.push(file);
|
|
6530
6550
|
if (status !== "active") continue;
|
|
@@ -6590,7 +6610,7 @@ async function checkArcaneManifest(targetDir) {
|
|
|
6590
6610
|
} catch {
|
|
6591
6611
|
const selfHostedManifestPath = join15(targetDir, "src", "assets", ".arcane.json");
|
|
6592
6612
|
try {
|
|
6593
|
-
const raw = await
|
|
6613
|
+
const raw = await readFile13(selfHostedManifestPath, "utf8");
|
|
6594
6614
|
const manifest = JSON.parse(raw);
|
|
6595
6615
|
if (isRecord3(manifest) && manifest["selfHosted"] === true && manifest["tracking_mode"] === "internal") {
|
|
6596
6616
|
return {
|
|
@@ -6609,7 +6629,7 @@ async function checkArcaneManifest(targetDir) {
|
|
|
6609
6629
|
};
|
|
6610
6630
|
}
|
|
6611
6631
|
try {
|
|
6612
|
-
const raw = await
|
|
6632
|
+
const raw = await readFile13(manifestPath2, "utf8");
|
|
6613
6633
|
const manifest = JSON.parse(raw);
|
|
6614
6634
|
if (!isRecord3(manifest) || typeof manifest["version"] !== "string" || !manifest["version"] || !Array.isArray(manifest["components"])) {
|
|
6615
6635
|
return {
|
|
@@ -6934,7 +6954,7 @@ async function checkMcpConfig(targetDir) {
|
|
|
6934
6954
|
const filePath = join15(targetDir, ".mcp.json");
|
|
6935
6955
|
let raw;
|
|
6936
6956
|
try {
|
|
6937
|
-
raw = await
|
|
6957
|
+
raw = await readFile13(filePath, "utf8");
|
|
6938
6958
|
} catch {
|
|
6939
6959
|
return { name, passed: true, blocking: false, message: "no .mcp.json \u2014 nothing to check" };
|
|
6940
6960
|
}
|
|
@@ -7017,7 +7037,7 @@ async function checkDelegations(targetDir) {
|
|
|
7017
7037
|
const filePath = join15(targetDir, ".arcane", "delegations.json");
|
|
7018
7038
|
let raw;
|
|
7019
7039
|
try {
|
|
7020
|
-
raw = await
|
|
7040
|
+
raw = await readFile13(filePath, "utf8");
|
|
7021
7041
|
} catch {
|
|
7022
7042
|
return { name, passed: true, blocking: false, message: "no delegations recorded" };
|
|
7023
7043
|
}
|
|
@@ -7237,16 +7257,16 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
|
|
|
7237
7257
|
}
|
|
7238
7258
|
|
|
7239
7259
|
// src/commands/ward.ts
|
|
7240
|
-
import { readFile as
|
|
7260
|
+
import { readFile as readFile15 } from "fs/promises";
|
|
7241
7261
|
|
|
7242
7262
|
// src/modules/ward.ts
|
|
7243
|
-
import { readdir as readdir10, readFile as
|
|
7263
|
+
import { readdir as readdir10, readFile as readFile14 } from "fs/promises";
|
|
7244
7264
|
import { join as join16, relative as relative2 } from "path";
|
|
7245
7265
|
async function scanFileAllRules(absolutePath, displayPath, rules) {
|
|
7246
7266
|
const findings = [];
|
|
7247
7267
|
let content;
|
|
7248
7268
|
try {
|
|
7249
|
-
content = await
|
|
7269
|
+
content = await readFile14(absolutePath, "utf8");
|
|
7250
7270
|
} catch {
|
|
7251
7271
|
return findings;
|
|
7252
7272
|
}
|
|
@@ -7341,7 +7361,7 @@ async function runWard(repoRoot, options = {}) {
|
|
|
7341
7361
|
const findings2 = await scanFileAllRules(absolutePath, displayPath, allRules);
|
|
7342
7362
|
if (findings2.length > 0) {
|
|
7343
7363
|
try {
|
|
7344
|
-
fileLines.set(displayPath, (await
|
|
7364
|
+
fileLines.set(displayPath, (await readFile14(absolutePath, "utf8")).split("\n"));
|
|
7345
7365
|
} catch {
|
|
7346
7366
|
}
|
|
7347
7367
|
}
|
|
@@ -7369,7 +7389,7 @@ function parseTermsFileContent(raw) {
|
|
|
7369
7389
|
}
|
|
7370
7390
|
async function readTermsFile(filePath) {
|
|
7371
7391
|
try {
|
|
7372
|
-
return parseTermsFileContent(await
|
|
7392
|
+
return parseTermsFileContent(await readFile15(filePath, "utf8"));
|
|
7373
7393
|
} catch (error) {
|
|
7374
7394
|
console.warn(
|
|
7375
7395
|
`\u26A0 arcane: could not read --terms-file "${filePath}": ${error.message}`
|
|
@@ -7579,17 +7599,732 @@ async function runBlockPush(targetDir) {
|
|
|
7579
7599
|
}
|
|
7580
7600
|
}
|
|
7581
7601
|
|
|
7602
|
+
// src/modules/credential-store.ts
|
|
7603
|
+
import { createHash as createHash2, randomBytes } from "crypto";
|
|
7604
|
+
var CHUNK_SIZE = 1e3;
|
|
7605
|
+
var MAX_CHUNKS = 64;
|
|
7606
|
+
var INDEX_NAME = "session/index";
|
|
7607
|
+
var CHUNK_PREFIX = "session/";
|
|
7608
|
+
var INDEX_VERSION = 1;
|
|
7609
|
+
var KeychainUnavailableError = class extends Error {
|
|
7610
|
+
constructor(operation, cause) {
|
|
7611
|
+
super(`The OS keychain is not available (${operation} failed).`);
|
|
7612
|
+
this.name = "KeychainUnavailableError";
|
|
7613
|
+
this.cause = cause;
|
|
7614
|
+
}
|
|
7615
|
+
};
|
|
7616
|
+
function sha256Hex(value) {
|
|
7617
|
+
return createHash2("sha256").update(value, "utf8").digest("hex");
|
|
7618
|
+
}
|
|
7619
|
+
function chunkName(gen, n) {
|
|
7620
|
+
return `${CHUNK_PREFIX}${gen}/${n}`;
|
|
7621
|
+
}
|
|
7622
|
+
function splitSecret(secret, size = CHUNK_SIZE) {
|
|
7623
|
+
const chunks = [];
|
|
7624
|
+
let start = 0;
|
|
7625
|
+
while (start < secret.length) {
|
|
7626
|
+
let end = Math.min(start + size, secret.length);
|
|
7627
|
+
if (end < secret.length) {
|
|
7628
|
+
const last = secret.charCodeAt(end - 1);
|
|
7629
|
+
if (last >= 55296 && last <= 56319) end -= 1;
|
|
7630
|
+
}
|
|
7631
|
+
chunks.push(secret.slice(start, end));
|
|
7632
|
+
start = end;
|
|
7633
|
+
}
|
|
7634
|
+
return chunks;
|
|
7635
|
+
}
|
|
7636
|
+
function parseIndex(raw) {
|
|
7637
|
+
let parsed;
|
|
7638
|
+
try {
|
|
7639
|
+
parsed = JSON.parse(raw);
|
|
7640
|
+
} catch {
|
|
7641
|
+
return null;
|
|
7642
|
+
}
|
|
7643
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
7644
|
+
const o = parsed;
|
|
7645
|
+
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"])) {
|
|
7646
|
+
return null;
|
|
7647
|
+
}
|
|
7648
|
+
return { v: o["v"], gen: o["gen"], count: o["count"], sha256: o["sha256"] };
|
|
7649
|
+
}
|
|
7650
|
+
var ChunkedSecretStore = class {
|
|
7651
|
+
constructor(backend) {
|
|
7652
|
+
this.backend = backend;
|
|
7653
|
+
}
|
|
7654
|
+
backend;
|
|
7655
|
+
guard(operation, fn) {
|
|
7656
|
+
try {
|
|
7657
|
+
return fn();
|
|
7658
|
+
} catch (error) {
|
|
7659
|
+
throw new KeychainUnavailableError(operation, error);
|
|
7660
|
+
}
|
|
7661
|
+
}
|
|
7662
|
+
/** Reads the stored secret. Never throws for bad data; throws only when the keychain itself fails. */
|
|
7663
|
+
load() {
|
|
7664
|
+
const raw = this.guard("read", () => this.backend.get(INDEX_NAME));
|
|
7665
|
+
if (raw === null) return { state: "signed-out" };
|
|
7666
|
+
const index = parseIndex(raw);
|
|
7667
|
+
if (index === null) return { state: "corrupt", reason: "the index entry is not valid" };
|
|
7668
|
+
const parts = [];
|
|
7669
|
+
for (let n = 0; n < index.count; n += 1) {
|
|
7670
|
+
const part = this.guard("read", () => this.backend.get(chunkName(index.gen, n)));
|
|
7671
|
+
if (part === null) return { state: "corrupt", reason: `chunk ${n} of ${index.count} is missing` };
|
|
7672
|
+
parts.push(part);
|
|
7673
|
+
}
|
|
7674
|
+
const secret = parts.join("");
|
|
7675
|
+
if (sha256Hex(secret) !== index.sha256) {
|
|
7676
|
+
return { state: "corrupt", reason: "the stored pieces do not match their checksum" };
|
|
7677
|
+
}
|
|
7678
|
+
return { state: "ok", secret };
|
|
7679
|
+
}
|
|
7680
|
+
/**
|
|
7681
|
+
* Replaces the stored secret. The previous session stays readable until the
|
|
7682
|
+
* new index is written; a failure before that leaves it untouched.
|
|
7683
|
+
*/
|
|
7684
|
+
save(secret) {
|
|
7685
|
+
if (secret.length === 0) throw new Error("Refusing to store an empty secret.");
|
|
7686
|
+
const gen = randomBytes(8).toString("hex");
|
|
7687
|
+
const chunks = splitSecret(secret);
|
|
7688
|
+
if (chunks.length > MAX_CHUNKS) {
|
|
7689
|
+
throw new Error(`Refusing to store a secret longer than ${MAX_CHUNKS * CHUNK_SIZE} characters.`);
|
|
7690
|
+
}
|
|
7691
|
+
chunks.forEach((chunk, n) => {
|
|
7692
|
+
this.guard("write", () => this.backend.set(chunkName(gen, n), chunk));
|
|
7693
|
+
});
|
|
7694
|
+
const index = { v: INDEX_VERSION, gen, count: chunks.length, sha256: sha256Hex(secret) };
|
|
7695
|
+
this.guard("write", () => this.backend.set(INDEX_NAME, JSON.stringify(index)));
|
|
7696
|
+
try {
|
|
7697
|
+
this.sweep(gen);
|
|
7698
|
+
} catch {
|
|
7699
|
+
}
|
|
7700
|
+
}
|
|
7701
|
+
/** Deletes everything this store wrote, strays included. Succeeds when nothing is stored. */
|
|
7702
|
+
clear() {
|
|
7703
|
+
this.guard("delete", () => this.backend.delete(INDEX_NAME));
|
|
7704
|
+
this.sweep(null);
|
|
7705
|
+
}
|
|
7706
|
+
/** Removes every chunk that does not belong to `keepGen` (all chunks when it is null). */
|
|
7707
|
+
sweep(keepGen) {
|
|
7708
|
+
const names = this.guard("list", () => this.backend.list());
|
|
7709
|
+
for (const name of names) {
|
|
7710
|
+
if (name === INDEX_NAME || !name.startsWith(CHUNK_PREFIX)) continue;
|
|
7711
|
+
if (keepGen !== null && name.startsWith(`${CHUNK_PREFIX}${keepGen}/`)) continue;
|
|
7712
|
+
this.guard("delete", () => this.backend.delete(name));
|
|
7713
|
+
}
|
|
7714
|
+
}
|
|
7715
|
+
};
|
|
7716
|
+
|
|
7717
|
+
// src/modules/identity-config.ts
|
|
7718
|
+
function defineIdentityEnvironment(config) {
|
|
7719
|
+
return {
|
|
7720
|
+
...config,
|
|
7721
|
+
discoveryUrl: `https://${config.signInHost}/${config.tenantId}/v2.0/.well-known/openid-configuration`
|
|
7722
|
+
};
|
|
7723
|
+
}
|
|
7724
|
+
var IDENTITY_ENVIRONMENTS = {
|
|
7725
|
+
production: defineIdentityEnvironment({
|
|
7726
|
+
environment: "production",
|
|
7727
|
+
tenantId: "9f8edbfb-291c-4c4a-b9cc-8820a04f8973",
|
|
7728
|
+
signInHost: "arcaneai.ciamlogin.com",
|
|
7729
|
+
clientId: "7cd8f05d-9979-4458-8ca2-47c67748c41f"
|
|
7730
|
+
}),
|
|
7731
|
+
development: defineIdentityEnvironment({
|
|
7732
|
+
environment: "development",
|
|
7733
|
+
tenantId: "b365de0e-3026-4afe-bf5b-556fccb4ffcf",
|
|
7734
|
+
signInHost: "arcaneaidev.ciamlogin.com",
|
|
7735
|
+
clientId: "651be720-83cc-452b-8346-abf20713ad12"
|
|
7736
|
+
})
|
|
7737
|
+
};
|
|
7738
|
+
var SIGN_IN_SCOPES = ["openid", "profile", "email", "offline_access"];
|
|
7739
|
+
var ENVIRONMENT_VARIABLE = "ARCANE_ENVIRONMENT";
|
|
7740
|
+
var UnknownEnvironmentError = class extends Error {
|
|
7741
|
+
constructor(value) {
|
|
7742
|
+
super(
|
|
7743
|
+
`${ENVIRONMENT_VARIABLE}=${JSON.stringify(value)} is not an environment. Use "production" (the default) or "development".`
|
|
7744
|
+
);
|
|
7745
|
+
this.name = "UnknownEnvironmentError";
|
|
7746
|
+
}
|
|
7747
|
+
};
|
|
7748
|
+
function resolveIdentityEnvironment(env = process.env) {
|
|
7749
|
+
const raw = (env[ENVIRONMENT_VARIABLE] ?? "").trim().toLowerCase();
|
|
7750
|
+
if (raw === "" || raw === "prod" || raw === "production") return IDENTITY_ENVIRONMENTS.production;
|
|
7751
|
+
if (raw === "dev" || raw === "development") return IDENTITY_ENVIRONMENTS.development;
|
|
7752
|
+
throw new UnknownEnvironmentError(env[ENVIRONMENT_VARIABLE] ?? "");
|
|
7753
|
+
}
|
|
7754
|
+
|
|
7755
|
+
// src/modules/oidc.ts
|
|
7756
|
+
import { createServer } from "http";
|
|
7757
|
+
import { execFile as execFile3 } from "child_process";
|
|
7758
|
+
import * as client from "openid-client";
|
|
7759
|
+
var IdentityUnreachableError = class extends Error {
|
|
7760
|
+
constructor(host, cause) {
|
|
7761
|
+
super(`Could not reach the identity service at ${host}.`);
|
|
7762
|
+
this.name = "IdentityUnreachableError";
|
|
7763
|
+
this.cause = cause;
|
|
7764
|
+
}
|
|
7765
|
+
};
|
|
7766
|
+
var LoginTimeoutError = class extends Error {
|
|
7767
|
+
constructor() {
|
|
7768
|
+
super("Timed out waiting for the sign-in to finish in the browser.");
|
|
7769
|
+
this.name = "LoginTimeoutError";
|
|
7770
|
+
}
|
|
7771
|
+
};
|
|
7772
|
+
var LoginDeniedError = class extends Error {
|
|
7773
|
+
constructor(code) {
|
|
7774
|
+
super(`The identity service refused the sign-in (${code}).`);
|
|
7775
|
+
this.name = "LoginDeniedError";
|
|
7776
|
+
}
|
|
7777
|
+
};
|
|
7778
|
+
async function loadIdentityConfiguration(environment, options = {}) {
|
|
7779
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
7780
|
+
let metadata;
|
|
7781
|
+
try {
|
|
7782
|
+
const response = await fetchImpl(environment.discoveryUrl, { redirect: "manual" });
|
|
7783
|
+
if (!response.ok) throw new Error(`discovery returned HTTP ${response.status}`);
|
|
7784
|
+
metadata = await response.json();
|
|
7785
|
+
} catch (error) {
|
|
7786
|
+
throw new IdentityUnreachableError(environment.signInHost, error);
|
|
7787
|
+
}
|
|
7788
|
+
if (typeof metadata.issuer !== "string" || !metadata.token_endpoint || !metadata.authorization_endpoint) {
|
|
7789
|
+
throw new IdentityUnreachableError(environment.signInHost, new Error("discovery document incomplete"));
|
|
7790
|
+
}
|
|
7791
|
+
const config = new client.Configuration(metadata, environment.clientId, void 0, client.None());
|
|
7792
|
+
if (options.fetch) config[client.customFetch] = options.fetch;
|
|
7793
|
+
if (options.allowInsecureHttp) client.allowInsecureRequests(config);
|
|
7794
|
+
return config;
|
|
7795
|
+
}
|
|
7796
|
+
function toTokenResult(tokens, previousRefreshToken) {
|
|
7797
|
+
const claims = tokens.claims();
|
|
7798
|
+
if (!claims?.sub) throw new Error("The identity service returned no account identifier.");
|
|
7799
|
+
const refreshToken = tokens.refresh_token ?? previousRefreshToken;
|
|
7800
|
+
if (!refreshToken) {
|
|
7801
|
+
throw new Error("The identity service returned no refresh token; nothing can be stored.");
|
|
7802
|
+
}
|
|
7803
|
+
const now = /* @__PURE__ */ new Date();
|
|
7804
|
+
const seconds = tokens.expiresIn() ?? 0;
|
|
7805
|
+
const email = typeof claims["email"] === "string" ? claims["email"] : typeof claims["preferred_username"] === "string" ? claims["preferred_username"] : null;
|
|
7806
|
+
return {
|
|
7807
|
+
refreshToken,
|
|
7808
|
+
sub: claims.sub,
|
|
7809
|
+
email,
|
|
7810
|
+
obtainedAt: now,
|
|
7811
|
+
expiresAt: new Date(now.getTime() + seconds * 1e3)
|
|
7812
|
+
};
|
|
7813
|
+
}
|
|
7814
|
+
var CLOSE_PAGE = [
|
|
7815
|
+
'<!doctype html><meta charset="utf-8"><title>Arcane</title>',
|
|
7816
|
+
'<body style="font-family:system-ui;margin:3rem"><h1>Signed in to Arcane</h1>',
|
|
7817
|
+
"<p>You can close this window and go back to the terminal.</p></body>"
|
|
7818
|
+
].join("");
|
|
7819
|
+
var FAILED_PAGE = [
|
|
7820
|
+
'<!doctype html><meta charset="utf-8"><title>Arcane</title>',
|
|
7821
|
+
'<body style="font-family:system-ui;margin:3rem"><h1>Sign-in not completed</h1>',
|
|
7822
|
+
"<p>Go back to the terminal for what to do next.</p></body>"
|
|
7823
|
+
].join("");
|
|
7824
|
+
async function browserLogin(config, options) {
|
|
7825
|
+
const codeVerifier = client.randomPKCECodeVerifier();
|
|
7826
|
+
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
|
|
7827
|
+
const state = client.randomState();
|
|
7828
|
+
const nonce = client.randomNonce();
|
|
7829
|
+
const server = createServer();
|
|
7830
|
+
await new Promise((resolve5, reject) => {
|
|
7831
|
+
server.once("error", reject);
|
|
7832
|
+
server.listen(0, "127.0.0.1", () => resolve5());
|
|
7833
|
+
});
|
|
7834
|
+
const address = server.address();
|
|
7835
|
+
if (address === null || typeof address === "string") {
|
|
7836
|
+
server.close();
|
|
7837
|
+
throw new Error("Could not open a loopback port for the sign-in.");
|
|
7838
|
+
}
|
|
7839
|
+
const redirectUri = new URL(`http://127.0.0.1:${address.port}`).href;
|
|
7840
|
+
const authorizationUrl = client.buildAuthorizationUrl(config, {
|
|
7841
|
+
redirect_uri: redirectUri,
|
|
7842
|
+
scope: options.scopes.join(" "),
|
|
7843
|
+
code_challenge: codeChallenge,
|
|
7844
|
+
code_challenge_method: "S256",
|
|
7845
|
+
state,
|
|
7846
|
+
nonce
|
|
7847
|
+
});
|
|
7848
|
+
const timeoutMs = options.timeoutMs ?? 5 * 60 * 1e3;
|
|
7849
|
+
let timer;
|
|
7850
|
+
const close = () => {
|
|
7851
|
+
if (timer) clearTimeout(timer);
|
|
7852
|
+
server.closeAllConnections?.();
|
|
7853
|
+
server.close();
|
|
7854
|
+
};
|
|
7855
|
+
const tokens = new Promise((resolve5, reject) => {
|
|
7856
|
+
server.on("request", (req, res) => {
|
|
7857
|
+
const url = new URL(req.url ?? "/", redirectUri);
|
|
7858
|
+
if (url.pathname !== "/" || !url.searchParams.has("code") && !url.searchParams.has("error")) {
|
|
7859
|
+
res.writeHead(404).end();
|
|
7860
|
+
return;
|
|
7861
|
+
}
|
|
7862
|
+
const errorCode = url.searchParams.get("error");
|
|
7863
|
+
if (errorCode) {
|
|
7864
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(FAILED_PAGE);
|
|
7865
|
+
reject(new LoginDeniedError(errorCode.replace(/[^a-z_]/gi, "")));
|
|
7866
|
+
return;
|
|
7867
|
+
}
|
|
7868
|
+
client.authorizationCodeGrant(config, url, {
|
|
7869
|
+
pkceCodeVerifier: codeVerifier,
|
|
7870
|
+
expectedState: state,
|
|
7871
|
+
expectedNonce: nonce,
|
|
7872
|
+
idTokenExpected: true
|
|
7873
|
+
}).then(async (response) => {
|
|
7874
|
+
const result = toTokenResult(response);
|
|
7875
|
+
await options.onValidated?.(result);
|
|
7876
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(CLOSE_PAGE);
|
|
7877
|
+
resolve5(result);
|
|
7878
|
+
}).catch((error) => {
|
|
7879
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(FAILED_PAGE);
|
|
7880
|
+
reject(error);
|
|
7881
|
+
});
|
|
7882
|
+
});
|
|
7883
|
+
timer = setTimeout(() => reject(new LoginTimeoutError()), timeoutMs);
|
|
7884
|
+
});
|
|
7885
|
+
tokens.catch(() => {
|
|
7886
|
+
});
|
|
7887
|
+
try {
|
|
7888
|
+
const opened = await options.openUrl(authorizationUrl.href);
|
|
7889
|
+
if (!opened) options.onCannotOpen?.(authorizationUrl.href);
|
|
7890
|
+
return await tokens;
|
|
7891
|
+
} finally {
|
|
7892
|
+
close();
|
|
7893
|
+
}
|
|
7894
|
+
}
|
|
7895
|
+
async function deviceLogin(config, options) {
|
|
7896
|
+
const handle = await client.initiateDeviceAuthorization(config, { scope: options.scopes.join(" ") });
|
|
7897
|
+
options.prompt(handle.verification_uri, handle.user_code, handle.expires_in);
|
|
7898
|
+
const response = await client.pollDeviceAuthorizationGrant(config, handle);
|
|
7899
|
+
return toTokenResult(response);
|
|
7900
|
+
}
|
|
7901
|
+
async function refreshSession(config, refreshToken, scopes) {
|
|
7902
|
+
const response = await client.refreshTokenGrant(config, refreshToken, { scope: scopes.join(" ") });
|
|
7903
|
+
return toTokenResult(response, refreshToken);
|
|
7904
|
+
}
|
|
7905
|
+
function openInDefaultBrowser(url) {
|
|
7906
|
+
const [command, args] = process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
|
|
7907
|
+
return new Promise((resolve5) => {
|
|
7908
|
+
try {
|
|
7909
|
+
const child = execFile3(command, args, { windowsHide: true }, (error) => resolve5(!error));
|
|
7910
|
+
child.on("error", () => resolve5(false));
|
|
7911
|
+
} catch {
|
|
7912
|
+
resolve5(false);
|
|
7913
|
+
}
|
|
7914
|
+
});
|
|
7915
|
+
}
|
|
7916
|
+
|
|
7917
|
+
// src/modules/session-store.ts
|
|
7918
|
+
import { execFileSync } from "child_process";
|
|
7919
|
+
import { mkdirSync, readFileSync as readFileSync2, renameSync, rmSync, writeFileSync, existsSync as existsSync2 } from "fs";
|
|
7920
|
+
import { userInfo } from "os";
|
|
7921
|
+
import { dirname as dirname10, join as join17 } from "path";
|
|
7922
|
+
|
|
7923
|
+
// src/modules/keychain-backend.ts
|
|
7924
|
+
import { createRequire as createRequire2 } from "module";
|
|
7925
|
+
var KEYCHAIN_SERVICE = "arcane-cli";
|
|
7926
|
+
function createKeychainBackend(service = KEYCHAIN_SERVICE) {
|
|
7927
|
+
let loaded = null;
|
|
7928
|
+
const keyring = () => {
|
|
7929
|
+
loaded ??= createRequire2(import.meta.url)("@napi-rs/keyring");
|
|
7930
|
+
return loaded;
|
|
7931
|
+
};
|
|
7932
|
+
const entry = (name) => new (keyring()).Entry(service, name);
|
|
7933
|
+
return {
|
|
7934
|
+
get: (name) => entry(name).getPassword() ?? null,
|
|
7935
|
+
set: (name, value) => {
|
|
7936
|
+
entry(name).setPassword(value);
|
|
7937
|
+
},
|
|
7938
|
+
delete: (name) => {
|
|
7939
|
+
entry(name).deletePassword();
|
|
7940
|
+
},
|
|
7941
|
+
list: () => keyring().findCredentials(service).map((credential) => credential.account)
|
|
7942
|
+
};
|
|
7943
|
+
}
|
|
7944
|
+
|
|
7945
|
+
// src/modules/session-store.ts
|
|
7946
|
+
var INSECURE_SESSION_FILE_NAME = "session-insecure.json";
|
|
7947
|
+
var SessionClearError = class extends Error {
|
|
7948
|
+
constructor(keychainError, removed) {
|
|
7949
|
+
super(keychainError.message);
|
|
7950
|
+
this.keychainError = keychainError;
|
|
7951
|
+
this.removed = removed;
|
|
7952
|
+
this.name = "SessionClearError";
|
|
7953
|
+
this.cause = keychainError;
|
|
7954
|
+
}
|
|
7955
|
+
keychainError;
|
|
7956
|
+
removed;
|
|
7957
|
+
};
|
|
7958
|
+
function isIsoDate(value) {
|
|
7959
|
+
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
|
7960
|
+
}
|
|
7961
|
+
function parseSessionRecord(raw) {
|
|
7962
|
+
let parsed;
|
|
7963
|
+
try {
|
|
7964
|
+
parsed = JSON.parse(raw);
|
|
7965
|
+
} catch {
|
|
7966
|
+
return null;
|
|
7967
|
+
}
|
|
7968
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
7969
|
+
const o = parsed;
|
|
7970
|
+
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"])) {
|
|
7971
|
+
return null;
|
|
7972
|
+
}
|
|
7973
|
+
return {
|
|
7974
|
+
v: 1,
|
|
7975
|
+
environment: o["environment"],
|
|
7976
|
+
sub: o["sub"],
|
|
7977
|
+
email: o["email"],
|
|
7978
|
+
refreshToken: o["refreshToken"],
|
|
7979
|
+
obtainedAt: o["obtainedAt"],
|
|
7980
|
+
expiresAt: o["expiresAt"]
|
|
7981
|
+
};
|
|
7982
|
+
}
|
|
7983
|
+
var FileSecretBackend = class {
|
|
7984
|
+
constructor(filePath, restrict = restrictToCurrentUser) {
|
|
7985
|
+
this.filePath = filePath;
|
|
7986
|
+
this.restrict = restrict;
|
|
7987
|
+
}
|
|
7988
|
+
filePath;
|
|
7989
|
+
restrict;
|
|
7990
|
+
exists() {
|
|
7991
|
+
return existsSync2(this.filePath);
|
|
7992
|
+
}
|
|
7993
|
+
read() {
|
|
7994
|
+
if (!existsSync2(this.filePath)) return {};
|
|
7995
|
+
const parsed = JSON.parse(readFileSync2(this.filePath, "utf8"));
|
|
7996
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
7997
|
+
throw new Error("the session file is not an object");
|
|
7998
|
+
}
|
|
7999
|
+
const out = {};
|
|
8000
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
8001
|
+
if (typeof v === "string") out[k] = v;
|
|
8002
|
+
}
|
|
8003
|
+
return out;
|
|
8004
|
+
}
|
|
8005
|
+
write(entries) {
|
|
8006
|
+
if (Object.keys(entries).length === 0) {
|
|
8007
|
+
rmSync(this.filePath, { force: true });
|
|
8008
|
+
return;
|
|
8009
|
+
}
|
|
8010
|
+
mkdirSync(dirname10(this.filePath), { recursive: true });
|
|
8011
|
+
const tmp = `${this.filePath}.${process.pid}.tmp`;
|
|
8012
|
+
writeFileSync(tmp, JSON.stringify(entries), { encoding: "utf8", mode: 384 });
|
|
8013
|
+
try {
|
|
8014
|
+
this.restrict(tmp);
|
|
8015
|
+
} catch (error) {
|
|
8016
|
+
rmSync(tmp, { force: true });
|
|
8017
|
+
throw new Error(
|
|
8018
|
+
"Could not restrict the session file to your account, so it was not written.",
|
|
8019
|
+
{ cause: error }
|
|
8020
|
+
);
|
|
8021
|
+
}
|
|
8022
|
+
renameSync(tmp, this.filePath);
|
|
8023
|
+
}
|
|
8024
|
+
get(name) {
|
|
8025
|
+
return this.read()[name] ?? null;
|
|
8026
|
+
}
|
|
8027
|
+
set(name, value) {
|
|
8028
|
+
const entries = this.read();
|
|
8029
|
+
entries[name] = value;
|
|
8030
|
+
this.write(entries);
|
|
8031
|
+
}
|
|
8032
|
+
delete(name) {
|
|
8033
|
+
const entries = this.read();
|
|
8034
|
+
if (!(name in entries)) return;
|
|
8035
|
+
delete entries[name];
|
|
8036
|
+
this.write(entries);
|
|
8037
|
+
}
|
|
8038
|
+
list() {
|
|
8039
|
+
return Object.keys(this.read());
|
|
8040
|
+
}
|
|
8041
|
+
/** Removes the file itself, strays and all. */
|
|
8042
|
+
remove() {
|
|
8043
|
+
rmSync(this.filePath, { force: true });
|
|
8044
|
+
}
|
|
8045
|
+
};
|
|
8046
|
+
function restrictToCurrentUser(filePath) {
|
|
8047
|
+
if (process.platform !== "win32") return;
|
|
8048
|
+
const account = userInfo().username;
|
|
8049
|
+
execFileSync("icacls", [filePath, "/inheritance:r", "/grant:r", `${account}:F`], {
|
|
8050
|
+
stdio: "ignore",
|
|
8051
|
+
windowsHide: true
|
|
8052
|
+
});
|
|
8053
|
+
}
|
|
8054
|
+
function insecureSessionFilePath(homeDir) {
|
|
8055
|
+
return join17(userTierRoot(homeDir), INSECURE_SESSION_FILE_NAME);
|
|
8056
|
+
}
|
|
8057
|
+
function createSessionStorage(options = {}) {
|
|
8058
|
+
const keychain = new ChunkedSecretStore(options.keychainBackend ?? createKeychainBackend());
|
|
8059
|
+
const fileBackend = options.fileBackend ?? new FileSecretBackend(insecureSessionFilePath());
|
|
8060
|
+
const file = new ChunkedSecretStore(fileBackend);
|
|
8061
|
+
const interpret = (result, source) => {
|
|
8062
|
+
if (result.state === "signed-out") return null;
|
|
8063
|
+
if (result.state === "corrupt") return { state: "corrupt", source, reason: result.reason };
|
|
8064
|
+
const record = parseSessionRecord(result.secret);
|
|
8065
|
+
if (record === null) return { state: "corrupt", source, reason: "the stored session is not valid" };
|
|
8066
|
+
return { state: "ok", source, record };
|
|
8067
|
+
};
|
|
8068
|
+
return {
|
|
8069
|
+
load() {
|
|
8070
|
+
let keychainError;
|
|
8071
|
+
try {
|
|
8072
|
+
const fromKeychain = interpret(keychain.load(), "keychain");
|
|
8073
|
+
if (fromKeychain) return fromKeychain;
|
|
8074
|
+
} catch (error) {
|
|
8075
|
+
if (!(error instanceof KeychainUnavailableError)) throw error;
|
|
8076
|
+
keychainError = error;
|
|
8077
|
+
}
|
|
8078
|
+
if (fileBackend.exists()) {
|
|
8079
|
+
const fromFile = interpret(file.load(), "file");
|
|
8080
|
+
if (fromFile) return fromFile;
|
|
8081
|
+
}
|
|
8082
|
+
return keychainError ? { state: "signed-out", keychainError } : { state: "signed-out" };
|
|
8083
|
+
},
|
|
8084
|
+
save(record, { insecure }) {
|
|
8085
|
+
const serialized = JSON.stringify(record);
|
|
8086
|
+
if (insecure) {
|
|
8087
|
+
file.save(serialized);
|
|
8088
|
+
try {
|
|
8089
|
+
keychain.clear();
|
|
8090
|
+
} catch {
|
|
8091
|
+
}
|
|
8092
|
+
return;
|
|
8093
|
+
}
|
|
8094
|
+
keychain.save(serialized);
|
|
8095
|
+
fileBackend.remove();
|
|
8096
|
+
},
|
|
8097
|
+
clear() {
|
|
8098
|
+
const removed = [];
|
|
8099
|
+
let keychainError;
|
|
8100
|
+
try {
|
|
8101
|
+
const hadKeychainSession = keychain.load().state !== "signed-out";
|
|
8102
|
+
keychain.clear();
|
|
8103
|
+
if (hadKeychainSession) removed.push("keychain");
|
|
8104
|
+
} catch (error) {
|
|
8105
|
+
keychainError = error;
|
|
8106
|
+
}
|
|
8107
|
+
if (fileBackend.exists()) {
|
|
8108
|
+
fileBackend.remove();
|
|
8109
|
+
removed.push("file");
|
|
8110
|
+
}
|
|
8111
|
+
if (keychainError instanceof KeychainUnavailableError) {
|
|
8112
|
+
throw new SessionClearError(keychainError, removed);
|
|
8113
|
+
}
|
|
8114
|
+
if (keychainError) throw keychainError;
|
|
8115
|
+
return { removed };
|
|
8116
|
+
}
|
|
8117
|
+
};
|
|
8118
|
+
}
|
|
8119
|
+
|
|
8120
|
+
// src/commands/login.ts
|
|
8121
|
+
function defaultLoginDeps() {
|
|
8122
|
+
return {
|
|
8123
|
+
storage: createSessionStorage(),
|
|
8124
|
+
environment: () => resolveIdentityEnvironment(),
|
|
8125
|
+
loadConfiguration: (environment) => loadIdentityConfiguration(environment),
|
|
8126
|
+
browserLogin,
|
|
8127
|
+
deviceLogin,
|
|
8128
|
+
openUrl: openInDefaultBrowser,
|
|
8129
|
+
isInteractive: () => Boolean(process.stdin.isTTY && process.stdout.isTTY)
|
|
8130
|
+
};
|
|
8131
|
+
}
|
|
8132
|
+
var INSECURE_STORAGE_WARNING = "Insecure storage: the session is kept in a file protected only by your account's file permissions, not in the OS keychain. Run `spell logout` to remove it.";
|
|
8133
|
+
function safeErrorText(error) {
|
|
8134
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
8135
|
+
return message.split("\n")[0].replace(/([?&](?:code|state|id_token|access_token|refresh_token)=)[^&\s]+/gi, "$1[redacted]");
|
|
8136
|
+
}
|
|
8137
|
+
function toSessionRecord(result, environment) {
|
|
8138
|
+
return {
|
|
8139
|
+
v: 1,
|
|
8140
|
+
environment: environment.environment,
|
|
8141
|
+
sub: result.sub,
|
|
8142
|
+
email: result.email,
|
|
8143
|
+
refreshToken: result.refreshToken,
|
|
8144
|
+
obtainedAt: result.obtainedAt.toISOString(),
|
|
8145
|
+
expiresAt: result.expiresAt.toISOString()
|
|
8146
|
+
};
|
|
8147
|
+
}
|
|
8148
|
+
function describeAccount(record) {
|
|
8149
|
+
const who = record.email ? `${record.email} (${record.sub})` : record.sub;
|
|
8150
|
+
return record.environment === "development" ? `${who} [development]` : who;
|
|
8151
|
+
}
|
|
8152
|
+
async function runLogin(options, deps = defaultLoginDeps()) {
|
|
8153
|
+
let environment;
|
|
8154
|
+
try {
|
|
8155
|
+
environment = deps.environment();
|
|
8156
|
+
} catch (error) {
|
|
8157
|
+
if (error instanceof UnknownEnvironmentError) {
|
|
8158
|
+
console.error(error.message);
|
|
8159
|
+
process.exit(1);
|
|
8160
|
+
return;
|
|
8161
|
+
}
|
|
8162
|
+
throw error;
|
|
8163
|
+
}
|
|
8164
|
+
if (!options.deviceCode && !deps.isInteractive()) {
|
|
8165
|
+
console.error(
|
|
8166
|
+
"No interactive terminal, so a browser sign-in cannot be started here. Run `spell login --device-code` and finish the sign-in on another device."
|
|
8167
|
+
);
|
|
8168
|
+
process.exit(1);
|
|
8169
|
+
return;
|
|
8170
|
+
}
|
|
8171
|
+
let config;
|
|
8172
|
+
try {
|
|
8173
|
+
config = await deps.loadConfiguration(environment);
|
|
8174
|
+
} catch (error) {
|
|
8175
|
+
console.error(
|
|
8176
|
+
error instanceof IdentityUnreachableError ? `${error.message} Check your network connection and try again.` : `Could not prepare the sign-in: ${safeErrorText(error)}`
|
|
8177
|
+
);
|
|
8178
|
+
process.exit(1);
|
|
8179
|
+
return;
|
|
8180
|
+
}
|
|
8181
|
+
let record;
|
|
8182
|
+
const saveValidatedSession = (result) => {
|
|
8183
|
+
const next = toSessionRecord(result, environment);
|
|
8184
|
+
deps.storage.save(next, { insecure: Boolean(options.insecureStorage) });
|
|
8185
|
+
record = next;
|
|
8186
|
+
};
|
|
8187
|
+
try {
|
|
8188
|
+
if (options.deviceCode) {
|
|
8189
|
+
const result = await deps.deviceLogin(config, {
|
|
8190
|
+
scopes: SIGN_IN_SCOPES,
|
|
8191
|
+
prompt: (uri, code, seconds) => {
|
|
8192
|
+
printInfo(`Open ${uri} on any device and enter the code ${code}.`);
|
|
8193
|
+
printInfo(`Waiting up to ${Math.round(seconds / 60)} minutes for you to finish signing in.`);
|
|
8194
|
+
}
|
|
8195
|
+
});
|
|
8196
|
+
saveValidatedSession(result);
|
|
8197
|
+
} else {
|
|
8198
|
+
printInfo("Opening your browser to sign in to Arcane. Come back here when it says you are signed in.");
|
|
8199
|
+
await deps.browserLogin(config, {
|
|
8200
|
+
scopes: SIGN_IN_SCOPES,
|
|
8201
|
+
openUrl: deps.openUrl,
|
|
8202
|
+
onValidated: saveValidatedSession,
|
|
8203
|
+
onCannotOpen: (url) => {
|
|
8204
|
+
printWarning("No browser could be opened. Open this address yourself, or run `spell login --device-code`:");
|
|
8205
|
+
printInfo(url);
|
|
8206
|
+
}
|
|
8207
|
+
});
|
|
8208
|
+
if (!record) throw new Error("The browser sign-in ended without storing a session.");
|
|
8209
|
+
}
|
|
8210
|
+
} catch (error) {
|
|
8211
|
+
if (error instanceof KeychainUnavailableError) {
|
|
8212
|
+
console.error(
|
|
8213
|
+
`${error.message} Nothing was stored. If this machine has no keychain, run \`spell login --insecure-storage\` to keep the session in a file instead (protected only by your account's file permissions).`
|
|
8214
|
+
);
|
|
8215
|
+
} else if (error instanceof LoginTimeoutError || error instanceof LoginDeniedError) {
|
|
8216
|
+
console.error(`${error.message} Nothing was changed.`);
|
|
8217
|
+
} else {
|
|
8218
|
+
console.error(`The sign-in did not complete: ${safeErrorText(error)}. Nothing was changed.`);
|
|
8219
|
+
}
|
|
8220
|
+
process.exit(1);
|
|
8221
|
+
return;
|
|
8222
|
+
}
|
|
8223
|
+
if (!record) throw new Error("The sign-in completed without a stored session.");
|
|
8224
|
+
printSuccess(`Signed in as ${describeAccount(record)}.`);
|
|
8225
|
+
if (options.insecureStorage) printWarning(INSECURE_STORAGE_WARNING);
|
|
8226
|
+
}
|
|
8227
|
+
|
|
8228
|
+
// src/commands/logout.ts
|
|
8229
|
+
function runLogout(storage = createSessionStorage()) {
|
|
8230
|
+
let removed;
|
|
8231
|
+
try {
|
|
8232
|
+
removed = storage.clear().removed;
|
|
8233
|
+
} catch (error) {
|
|
8234
|
+
if (error instanceof SessionClearError) {
|
|
8235
|
+
const fileResult = error.removed.includes("file") ? "The insecure session file was removed. " : "";
|
|
8236
|
+
console.error(
|
|
8237
|
+
`${fileResult}${error.keychainError.message} Any session in the OS keychain could not be verified or removed.`
|
|
8238
|
+
);
|
|
8239
|
+
process.exit(1);
|
|
8240
|
+
return;
|
|
8241
|
+
}
|
|
8242
|
+
if (error instanceof KeychainUnavailableError) {
|
|
8243
|
+
console.error(`${error.message} The session could not be removed from it.`);
|
|
8244
|
+
process.exit(1);
|
|
8245
|
+
return;
|
|
8246
|
+
}
|
|
8247
|
+
throw error;
|
|
8248
|
+
}
|
|
8249
|
+
if (removed.length === 0) {
|
|
8250
|
+
printSuccess("Nothing was stored; you were already signed out.");
|
|
8251
|
+
return;
|
|
8252
|
+
}
|
|
8253
|
+
const where = removed.map((m) => m === "keychain" ? "the OS keychain" : "the insecure session file").join(" and ");
|
|
8254
|
+
printSuccess(`Signed out: removed the session from ${where}.`);
|
|
8255
|
+
printInfo("This signs out this machine only; the session at the identity service stays valid until it expires.");
|
|
8256
|
+
}
|
|
8257
|
+
|
|
8258
|
+
// src/commands/whoami.ts
|
|
8259
|
+
function defaultWhoamiDeps() {
|
|
8260
|
+
return {
|
|
8261
|
+
storage: createSessionStorage(),
|
|
8262
|
+
loadConfiguration: (environment) => loadIdentityConfiguration(environment),
|
|
8263
|
+
refreshSession,
|
|
8264
|
+
now: () => /* @__PURE__ */ new Date()
|
|
8265
|
+
};
|
|
8266
|
+
}
|
|
8267
|
+
async function runWhoami(options, deps = defaultWhoamiDeps()) {
|
|
8268
|
+
let loaded;
|
|
8269
|
+
try {
|
|
8270
|
+
loaded = deps.storage.load();
|
|
8271
|
+
} catch (error) {
|
|
8272
|
+
if (error instanceof KeychainUnavailableError) {
|
|
8273
|
+
console.error(`${error.message} Cannot tell whether you are signed in.`);
|
|
8274
|
+
process.exit(1);
|
|
8275
|
+
return;
|
|
8276
|
+
}
|
|
8277
|
+
throw error;
|
|
8278
|
+
}
|
|
8279
|
+
if (loaded.state === "signed-out") {
|
|
8280
|
+
console.error("Not signed in. Run `spell login`.");
|
|
8281
|
+
if (loaded.keychainError) console.error(loaded.keychainError.message);
|
|
8282
|
+
process.exit(1);
|
|
8283
|
+
return;
|
|
8284
|
+
}
|
|
8285
|
+
if (loaded.state === "corrupt") {
|
|
8286
|
+
console.error(
|
|
8287
|
+
`The stored session is damaged (${loaded.reason}), so you are treated as signed out. Run \`spell logout\` to clear it, then \`spell login\`.`
|
|
8288
|
+
);
|
|
8289
|
+
process.exit(1);
|
|
8290
|
+
return;
|
|
8291
|
+
}
|
|
8292
|
+
const { record, source } = loaded;
|
|
8293
|
+
printSuccess(`Signed in as ${describeAccount(record)}.`);
|
|
8294
|
+
const expires = new Date(record.expiresAt);
|
|
8295
|
+
const expired = expires.getTime() <= deps.now().getTime();
|
|
8296
|
+
printInfo(`Signed in on ${new Date(record.obtainedAt).toLocaleString()}.`);
|
|
8297
|
+
printInfo(
|
|
8298
|
+
expired ? `The last token expired on ${expires.toLocaleString()}; the session refreshes on next use.` : `The current token is valid until ${expires.toLocaleString()}.`
|
|
8299
|
+
);
|
|
8300
|
+
printInfo(`Stored in ${source === "keychain" ? "the OS keychain" : "the insecure session file"}.`);
|
|
8301
|
+
if (source === "file") printWarning(INSECURE_STORAGE_WARNING);
|
|
8302
|
+
if (!options.verify) return;
|
|
8303
|
+
const environment = IDENTITY_ENVIRONMENTS[record.environment];
|
|
8304
|
+
try {
|
|
8305
|
+
const config = await deps.loadConfiguration(environment);
|
|
8306
|
+
const fresh = await deps.refreshSession(config, record.refreshToken, SIGN_IN_SCOPES);
|
|
8307
|
+
deps.storage.save(toSessionRecord(fresh, environment), { insecure: source === "file" });
|
|
8308
|
+
printSuccess(`Verified with the identity service at ${environment.signInHost}; the session is active.`);
|
|
8309
|
+
} catch (error) {
|
|
8310
|
+
console.error(
|
|
8311
|
+
`The identity service did not accept the session (${safeErrorText(error)}). Run \`spell login\` again.`
|
|
8312
|
+
);
|
|
8313
|
+
process.exit(1);
|
|
8314
|
+
}
|
|
8315
|
+
}
|
|
8316
|
+
|
|
7582
8317
|
// src/commands/report.ts
|
|
7583
8318
|
import { access as access5 } from "fs/promises";
|
|
7584
|
-
import { join as
|
|
8319
|
+
import { join as join20 } from "path";
|
|
7585
8320
|
|
|
7586
8321
|
// src/modules/show-report/generate.ts
|
|
7587
|
-
import { mkdir as mkdir8, readdir as readdir11, readFile as
|
|
7588
|
-
import { basename as basename3, dirname as
|
|
8322
|
+
import { mkdir as mkdir8, readdir as readdir11, readFile as readFile17, writeFile as writeFile12 } from "fs/promises";
|
|
8323
|
+
import { basename as basename3, dirname as dirname11, isAbsolute, join as join19, relative as relative3, resolve as resolve4 } from "path";
|
|
7589
8324
|
|
|
7590
8325
|
// src/modules/show-report/model.ts
|
|
7591
|
-
import { readFile as
|
|
7592
|
-
import { join as
|
|
8326
|
+
import { readFile as readFile16 } from "fs/promises";
|
|
8327
|
+
import { join as join18 } from "path";
|
|
7593
8328
|
|
|
7594
8329
|
// src/modules/show-report/plan-parser.ts
|
|
7595
8330
|
function parseFrontmatter(content) {
|
|
@@ -7862,7 +8597,7 @@ async function getCast(cwd, fromRef, toRef, generatedOutputs = []) {
|
|
|
7862
8597
|
// src/modules/show-report/model.ts
|
|
7863
8598
|
async function readOptional(path4) {
|
|
7864
8599
|
try {
|
|
7865
|
-
return await
|
|
8600
|
+
return await readFile16(path4, "utf8");
|
|
7866
8601
|
} catch {
|
|
7867
8602
|
return null;
|
|
7868
8603
|
}
|
|
@@ -7958,13 +8693,13 @@ function buildCorrections(ledgerRows, programName) {
|
|
|
7958
8693
|
};
|
|
7959
8694
|
}
|
|
7960
8695
|
async function buildShowReportModel(options) {
|
|
7961
|
-
const planAbsPath =
|
|
7962
|
-
const queueAbsPath =
|
|
8696
|
+
const planAbsPath = join18(options.rootDir, options.planRelPath);
|
|
8697
|
+
const queueAbsPath = join18(options.rootDir, options.queueRelPath);
|
|
7963
8698
|
const ledgerRelPath = "docs/verification-ledger.md";
|
|
7964
|
-
const ledgerAbsPath =
|
|
7965
|
-
const decisionsAbsPath =
|
|
8699
|
+
const ledgerAbsPath = join18(options.rootDir, ledgerRelPath);
|
|
8700
|
+
const decisionsAbsPath = join18(options.rootDir, "DECISIONS.md");
|
|
7966
8701
|
const [planContent, queueContent, ledgerContent, decisionsContent] = await Promise.all([
|
|
7967
|
-
|
|
8702
|
+
readFile16(planAbsPath, "utf8"),
|
|
7968
8703
|
readOptional(queueAbsPath),
|
|
7969
8704
|
readOptional(ledgerAbsPath),
|
|
7970
8705
|
readOptional(decisionsAbsPath)
|
|
@@ -8160,7 +8895,7 @@ function toPosix(path4) {
|
|
|
8160
8895
|
async function programFromPlan(rootDir, slug, planRelPath) {
|
|
8161
8896
|
let planContent;
|
|
8162
8897
|
try {
|
|
8163
|
-
planContent = await
|
|
8898
|
+
planContent = await readFile17(join19(rootDir, planRelPath), "utf8");
|
|
8164
8899
|
} catch {
|
|
8165
8900
|
return { skipped: { slug, reason: `no PLAN.md at ${planRelPath}` } };
|
|
8166
8901
|
}
|
|
@@ -8172,13 +8907,13 @@ async function programFromPlan(rootDir, slug, planRelPath) {
|
|
|
8172
8907
|
program: {
|
|
8173
8908
|
slug,
|
|
8174
8909
|
planRelPath,
|
|
8175
|
-
queueRelPath: toPosix(
|
|
8910
|
+
queueRelPath: toPosix(join19(dirname11(planRelPath), "OPERATOR-QUEUE.md")),
|
|
8176
8911
|
ledgerProgramName: programNameFromTitle(frontmatter.title ?? slug)
|
|
8177
8912
|
}
|
|
8178
8913
|
};
|
|
8179
8914
|
}
|
|
8180
8915
|
async function discoverPrograms(rootDir) {
|
|
8181
|
-
const plansDir =
|
|
8916
|
+
const plansDir = join19(rootDir, "docs", "plans");
|
|
8182
8917
|
let entries;
|
|
8183
8918
|
try {
|
|
8184
8919
|
entries = (await readdir11(plansDir, { withFileTypes: true })).filter((e) => e.isDirectory());
|
|
@@ -8198,21 +8933,21 @@ async function discoverPrograms(rootDir) {
|
|
|
8198
8933
|
async function programFromPlanPath(rootDir, planPath) {
|
|
8199
8934
|
const planAbs = isAbsolute(planPath) ? planPath : resolve4(rootDir, planPath);
|
|
8200
8935
|
const planRelPath = toPosix(relative3(rootDir, planAbs));
|
|
8201
|
-
return programFromPlan(rootDir, basename3(
|
|
8936
|
+
return programFromPlan(rootDir, basename3(dirname11(planAbs)), planRelPath);
|
|
8202
8937
|
}
|
|
8203
8938
|
function normalizeLineEndings2(content) {
|
|
8204
8939
|
return content.replace(/\r\n?/g, "\n");
|
|
8205
8940
|
}
|
|
8206
8941
|
async function readOptionalFile(path4) {
|
|
8207
8942
|
try {
|
|
8208
|
-
return await
|
|
8943
|
+
return await readFile17(path4, "utf8");
|
|
8209
8944
|
} catch (error) {
|
|
8210
8945
|
if (error.code === "ENOENT") return null;
|
|
8211
8946
|
throw error;
|
|
8212
8947
|
}
|
|
8213
8948
|
}
|
|
8214
8949
|
async function generateReport(rootDir, program2, templateHtml, theme = "auto") {
|
|
8215
|
-
const planContent = await
|
|
8950
|
+
const planContent = await readFile17(join19(rootDir, program2.planRelPath), "utf8");
|
|
8216
8951
|
const frontmatter = parseFrontmatter(planContent);
|
|
8217
8952
|
const model = await buildShowReportModel({
|
|
8218
8953
|
rootDir,
|
|
@@ -8232,7 +8967,7 @@ async function generateReport(rootDir, program2, templateHtml, theme = "auto") {
|
|
|
8232
8967
|
};
|
|
8233
8968
|
}
|
|
8234
8969
|
async function runReportGeneration(options) {
|
|
8235
|
-
const templateHtml = await
|
|
8970
|
+
const templateHtml = await readFile17(options.templatePath, "utf8");
|
|
8236
8971
|
const theme = options.theme ?? "auto";
|
|
8237
8972
|
const drifted = [];
|
|
8238
8973
|
const repaired = [];
|
|
@@ -8243,19 +8978,19 @@ async function runReportGeneration(options) {
|
|
|
8243
8978
|
if (!generated.themeApplied) themeSkipped.push(program2.slug);
|
|
8244
8979
|
const missing = JSON.parse(generated.json).sections.flatMap((section) => section.rows).filter((row) => row.descriptionState === "unwritten").map((row) => row.id);
|
|
8245
8980
|
if (missing.length > 0) unwritten.push({ slug: program2.slug, ids: missing });
|
|
8246
|
-
const outAbs = options.outDir ? isAbsolute(options.outDir) ? options.outDir : resolve4(options.rootDir, options.outDir) : resolve4(options.rootDir,
|
|
8981
|
+
const outAbs = options.outDir ? isAbsolute(options.outDir) ? options.outDir : resolve4(options.rootDir, options.outDir) : resolve4(options.rootDir, dirname11(program2.planRelPath));
|
|
8247
8982
|
const outRel = toPosix(relative3(options.rootDir, outAbs));
|
|
8248
8983
|
const outputs = [
|
|
8249
8984
|
[`${outRel}/show-report.json`, generated.json],
|
|
8250
8985
|
[`${outRel}/show-report.html`, generated.html]
|
|
8251
8986
|
];
|
|
8252
8987
|
for (const [relPath, expected] of outputs) {
|
|
8253
|
-
const actual = await readOptionalFile(
|
|
8988
|
+
const actual = await readOptionalFile(join19(options.rootDir, relPath));
|
|
8254
8989
|
if (actual === null || normalizeLineEndings2(actual) !== normalizeLineEndings2(expected)) {
|
|
8255
8990
|
drifted.push(`${relPath} does not match a regeneration from its sources.`);
|
|
8256
8991
|
if (options.mode === "write") {
|
|
8257
8992
|
await mkdir8(outAbs, { recursive: true });
|
|
8258
|
-
await writeFile12(
|
|
8993
|
+
await writeFile12(join19(options.rootDir, relPath), expected, "utf8");
|
|
8259
8994
|
repaired.push(relPath);
|
|
8260
8995
|
}
|
|
8261
8996
|
}
|
|
@@ -8266,7 +9001,7 @@ async function runReportGeneration(options) {
|
|
|
8266
9001
|
|
|
8267
9002
|
// src/commands/report.ts
|
|
8268
9003
|
async function runReport(targetDir, options = {}, assetsDir) {
|
|
8269
|
-
const templatePath =
|
|
9004
|
+
const templatePath = join20(assetsDir, "report", "show-report.template.html");
|
|
8270
9005
|
try {
|
|
8271
9006
|
await access5(templatePath);
|
|
8272
9007
|
} catch {
|
|
@@ -8341,9 +9076,9 @@ async function runReport(targetDir, options = {}, assetsDir) {
|
|
|
8341
9076
|
|
|
8342
9077
|
// src/index.ts
|
|
8343
9078
|
import { realpathSync } from "fs";
|
|
8344
|
-
var require3 =
|
|
9079
|
+
var require3 = createRequire3(import.meta.url);
|
|
8345
9080
|
var pkg = require3("../package.json");
|
|
8346
|
-
var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ??
|
|
9081
|
+
var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join21(dirname12(fileURLToPath(import.meta.url)), "assets");
|
|
8347
9082
|
var program = new Command();
|
|
8348
9083
|
var USER_FLAG_DESCRIPTION = "Operate on the per-user tier at ~/.arcane \u2014 spells shared by every repository on this machine, fanned out to ~/.agents/skills (Codex, Copilot) and ~/.claude/commands (Claude Code)";
|
|
8349
9084
|
var USER_TIER_AGENTS_DESCRIPTION = "Operate on the per-user tier at ~/.arcane \u2014 one agent roster shared by every repository on this machine, rendered to ~/.copilot/agents (VS Code agent modes)";
|
|
@@ -8466,6 +9201,18 @@ program.command("block-push").description("Enforce push_policy: blocked on this
|
|
|
8466
9201
|
program.command("unblock-push").description("Lift this repository's push block (interactive terminal only)").action(async () => {
|
|
8467
9202
|
await runUnblockPush(process.cwd());
|
|
8468
9203
|
});
|
|
9204
|
+
program.command("login").description("Sign in to your Arcane account (browser by default; the session is kept in the OS keychain)").option("--device-code", "Sign in from another device by typing a code; for machines with no browser").option(
|
|
9205
|
+
"--insecure-storage",
|
|
9206
|
+
"Keep the session in a file under ~/.arcane instead of the OS keychain; protected only by your account's file permissions"
|
|
9207
|
+
).action(async (opts) => {
|
|
9208
|
+
await runLogin({ deviceCode: opts.deviceCode, insecureStorage: opts.insecureStorage });
|
|
9209
|
+
});
|
|
9210
|
+
program.command("logout").description("Sign out on this machine: remove the stored session").action(() => {
|
|
9211
|
+
runLogout();
|
|
9212
|
+
});
|
|
9213
|
+
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) => {
|
|
9214
|
+
await runWhoami({ verify: opts.verify });
|
|
9215
|
+
});
|
|
8469
9216
|
program.command("doctor").description("Check your environment for Arcane prerequisites").option("--fix", "Automatically create missing session continuity files").option("--leaks", "Run only the on-demand secrets/credential scan (ARC-037), skipping the default checks").action(async (opts) => {
|
|
8470
9217
|
await runDoctor(
|
|
8471
9218
|
process.cwd(),
|