@dayofweek/dcli 1.3.0 → 1.5.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 +107 -54
- package/dist/auth/login.d.ts +18 -0
- package/dist/auth/login.js +47 -0
- package/dist/auth/loopback.d.ts +6 -0
- package/dist/auth/loopback.js +59 -0
- package/dist/auth/pkce.d.ts +14 -0
- package/dist/auth/pkce.js +87 -0
- package/dist/bin/dcli.js +576 -58
- package/dist/client.d.ts +205 -2
- package/dist/client.js +280 -44
- package/dist/config.d.ts +4 -1
- package/dist/config.js +25 -7
- package/dist/credentials.d.ts +13 -0
- package/dist/credentials.js +113 -0
- package/dist/skills.d.ts +16 -0
- package/dist/skills.js +77 -0
- package/dist/uri.d.ts +9 -0
- package/dist/uri.js +72 -0
- package/package.json +9 -2
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { homedir, platform, userInfo } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
const SERVICE = "com.dayofweek.dcli";
|
|
6
|
+
const ACCOUNT = "brain-device";
|
|
7
|
+
export class ProtectedFileCredentialStore {
|
|
8
|
+
path;
|
|
9
|
+
constructor(path = join(homedir(), ".config", "dayofweek", "credentials.json")) {
|
|
10
|
+
this.path = path;
|
|
11
|
+
}
|
|
12
|
+
get() {
|
|
13
|
+
if (!existsSync(this.path))
|
|
14
|
+
return undefined;
|
|
15
|
+
if (platform() !== "win32" && (statSync(this.path).mode & 0o077) !== 0) {
|
|
16
|
+
throw new Error(`Credential file permissions are unsafe: ${this.path}`);
|
|
17
|
+
}
|
|
18
|
+
const value = JSON.parse(readFileSync(this.path, "utf8"));
|
|
19
|
+
return typeof value.deviceSecret === "string" ? value.deviceSecret : undefined;
|
|
20
|
+
}
|
|
21
|
+
set(secret) {
|
|
22
|
+
if (!/^dsk_[A-Za-z0-9_-]{20,160}$/.test(secret))
|
|
23
|
+
throw new Error("Refusing to store an invalid credential");
|
|
24
|
+
const directory = dirname(this.path);
|
|
25
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
26
|
+
if (platform() !== "win32")
|
|
27
|
+
chmodSync(directory, 0o700);
|
|
28
|
+
writeFileSync(this.path, JSON.stringify({ deviceSecret: secret }), {
|
|
29
|
+
encoding: "utf8",
|
|
30
|
+
mode: 0o600,
|
|
31
|
+
flag: "w",
|
|
32
|
+
});
|
|
33
|
+
if (platform() !== "win32") {
|
|
34
|
+
chmodSync(this.path, 0o600);
|
|
35
|
+
if ((statSync(this.path).mode & 0o077) !== 0)
|
|
36
|
+
throw new Error("Could not secure credential file");
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
const username = userInfo().username;
|
|
40
|
+
execFileSync("icacls", [this.path, "/inheritance:r", "/grant:r", `${username}:(R,W)`], {
|
|
41
|
+
stdio: "ignore",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
delete() {
|
|
46
|
+
if (existsSync(this.path))
|
|
47
|
+
unlinkSync(this.path);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
class MacKeychainCredentialStore {
|
|
51
|
+
get() {
|
|
52
|
+
try {
|
|
53
|
+
return execFileSync("security", ["find-generic-password", "-s", SERVICE, "-a", ACCOUNT, "-w"], {
|
|
54
|
+
encoding: "utf8",
|
|
55
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
56
|
+
}).trim() || undefined;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
set(secret) {
|
|
63
|
+
execFileSync("security", ["add-generic-password", "-U", "-s", SERVICE, "-a", ACCOUNT, "-w", secret], { stdio: "ignore" });
|
|
64
|
+
}
|
|
65
|
+
delete() {
|
|
66
|
+
try {
|
|
67
|
+
execFileSync("security", ["delete-generic-password", "-s", SERVICE, "-a", ACCOUNT], {
|
|
68
|
+
stdio: "ignore",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Missing credential is already logged out.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
class LinuxSecretServiceCredentialStore {
|
|
77
|
+
get() {
|
|
78
|
+
try {
|
|
79
|
+
return execFileSync("secret-tool", ["lookup", "service", SERVICE, "account", ACCOUNT], {
|
|
80
|
+
encoding: "utf8",
|
|
81
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
82
|
+
}).trim() || undefined;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
set(secret) {
|
|
89
|
+
execFileSync("secret-tool", ["store", "--label", "Day of Week device", "service", SERVICE, "account", ACCOUNT], { input: secret, stdio: ["pipe", "ignore", "ignore"] });
|
|
90
|
+
}
|
|
91
|
+
delete() {
|
|
92
|
+
try {
|
|
93
|
+
execFileSync("secret-tool", ["clear", "service", SERVICE, "account", ACCOUNT], { stdio: "ignore" });
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Missing credential is already logged out.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export function defaultCredentialStore() {
|
|
101
|
+
if (platform() === "darwin")
|
|
102
|
+
return new MacKeychainCredentialStore();
|
|
103
|
+
if (platform() === "linux") {
|
|
104
|
+
try {
|
|
105
|
+
execFileSync("secret-tool", ["--version"], { stdio: "ignore" });
|
|
106
|
+
return new LinuxSecretServiceCredentialStore();
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return new ProtectedFileCredentialStore();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return new ProtectedFileCredentialStore();
|
|
113
|
+
}
|
package/dist/skills.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type SkillBundle = {
|
|
2
|
+
name: string;
|
|
3
|
+
version: string;
|
|
4
|
+
hash?: string;
|
|
5
|
+
files: Array<{
|
|
6
|
+
path: string;
|
|
7
|
+
content: string;
|
|
8
|
+
sha256?: string;
|
|
9
|
+
}>;
|
|
10
|
+
};
|
|
11
|
+
export declare function validateSkillBundle(bundle: SkillBundle): SkillBundle;
|
|
12
|
+
export declare function writeSkillBundle(input: SkillBundle, targetDir: string): {
|
|
13
|
+
written: number;
|
|
14
|
+
unchanged: number;
|
|
15
|
+
conflicts: string[];
|
|
16
|
+
};
|
package/dist/skills.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
function sha256(value) {
|
|
5
|
+
return createHash("sha256").update(value).digest("hex");
|
|
6
|
+
}
|
|
7
|
+
export function validateSkillBundle(bundle) {
|
|
8
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(bundle.name) || !bundle.version.trim() || bundle.version.length > 64) {
|
|
9
|
+
throw new Error("Skill bundle metadata is invalid");
|
|
10
|
+
}
|
|
11
|
+
if (bundle.files.length === 0 || bundle.files.length > 64)
|
|
12
|
+
throw new Error("Skill bundle file count is invalid");
|
|
13
|
+
const paths = new Set();
|
|
14
|
+
const files = bundle.files.map((file) => {
|
|
15
|
+
const segments = file.path.split("/");
|
|
16
|
+
if (!file.path ||
|
|
17
|
+
file.path.startsWith("/") ||
|
|
18
|
+
file.path.includes("\\") ||
|
|
19
|
+
file.path.includes("\0") ||
|
|
20
|
+
segments.some((segment) => !segment || segment === "." || segment === "..") ||
|
|
21
|
+
paths.has(file.path) ||
|
|
22
|
+
Buffer.byteLength(file.content) > 1_000_000) {
|
|
23
|
+
throw new Error("Skill bundle contained an unsafe file");
|
|
24
|
+
}
|
|
25
|
+
paths.add(file.path);
|
|
26
|
+
const actual = sha256(file.content);
|
|
27
|
+
if (file.sha256 && file.sha256 !== actual)
|
|
28
|
+
throw new Error("Skill bundle file checksum mismatch");
|
|
29
|
+
return { ...file, sha256: actual };
|
|
30
|
+
});
|
|
31
|
+
const actualBundleHash = sha256(files.map((file) => `${file.path}\0${file.sha256}`).join("\0"));
|
|
32
|
+
if (bundle.hash && bundle.hash !== actualBundleHash)
|
|
33
|
+
throw new Error("Skill bundle manifest checksum mismatch");
|
|
34
|
+
return { ...bundle, hash: actualBundleHash, files };
|
|
35
|
+
}
|
|
36
|
+
export function writeSkillBundle(input, targetDir) {
|
|
37
|
+
const bundle = validateSkillBundle(input);
|
|
38
|
+
let filesWritten = 0;
|
|
39
|
+
let unchanged = 0;
|
|
40
|
+
const conflicts = [];
|
|
41
|
+
const metadataPath = join(targetDir, ".dayofweek-skill.json");
|
|
42
|
+
const previous = existsSync(metadataPath)
|
|
43
|
+
? JSON.parse(readFileSync(metadataPath, "utf8"))
|
|
44
|
+
: undefined;
|
|
45
|
+
const hashes = {};
|
|
46
|
+
for (const file of bundle.files) {
|
|
47
|
+
const filePath = join(targetDir, file.path);
|
|
48
|
+
const nextHash = file.sha256;
|
|
49
|
+
hashes[file.path] = nextHash;
|
|
50
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
51
|
+
if (existsSync(filePath)) {
|
|
52
|
+
const currentHash = sha256(readFileSync(filePath));
|
|
53
|
+
if (currentHash === nextHash) {
|
|
54
|
+
unchanged++;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (previous?.files?.[file.path] !== currentHash) {
|
|
58
|
+
let suffix = 0;
|
|
59
|
+
let conflictPath = `${filePath}.new`;
|
|
60
|
+
while (existsSync(conflictPath) && suffix < 999) {
|
|
61
|
+
suffix++;
|
|
62
|
+
conflictPath = `${filePath}.new.${suffix}`;
|
|
63
|
+
}
|
|
64
|
+
if (existsSync(conflictPath))
|
|
65
|
+
throw new Error("Too many unresolved skill update conflicts");
|
|
66
|
+
writeFileSync(conflictPath, file.content, { encoding: "utf8", flag: "wx" });
|
|
67
|
+
conflicts.push(suffix === 0 ? `${file.path}.new` : `${file.path}.new.${suffix}`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
writeFileSync(filePath, file.content, "utf-8");
|
|
72
|
+
filesWritten++;
|
|
73
|
+
}
|
|
74
|
+
mkdirSync(targetDir, { recursive: true });
|
|
75
|
+
writeFileSync(metadataPath, JSON.stringify({ name: bundle.name, version: bundle.version, hash: bundle.hash, files: hashes }, null, 2), "utf8");
|
|
76
|
+
return { written: filesWritten, unchanged, conflicts };
|
|
77
|
+
}
|
package/dist/uri.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type BrainResource = {
|
|
2
|
+
version: 1;
|
|
3
|
+
areaId: string;
|
|
4
|
+
resourceType: "area" | "note" | "source";
|
|
5
|
+
resourceId?: string;
|
|
6
|
+
};
|
|
7
|
+
export declare function parseBrainResource(input: string): BrainResource;
|
|
8
|
+
export declare function toBrainUri(resource: BrainResource): string;
|
|
9
|
+
export declare function toBrainHttpsUrl(resource: BrainResource): string;
|
package/dist/uri.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const HTTPS_ORIGIN = "https://field.dayofweek.com";
|
|
2
|
+
const HTTPS_PREFIX = "/app/brain/";
|
|
3
|
+
const ID_PATTERN = /^[A-Za-z0-9_-]{2,128}$/;
|
|
4
|
+
function assertId(value, label) {
|
|
5
|
+
if (!value || !ID_PATTERN.test(value)) {
|
|
6
|
+
throw new Error(`Invalid ${label}`);
|
|
7
|
+
}
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
function parseSegments(pathname) {
|
|
11
|
+
if (pathname.includes("%"))
|
|
12
|
+
throw new Error("Encoded brain URI paths are not supported");
|
|
13
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
14
|
+
if (segments.length === 1) {
|
|
15
|
+
return {
|
|
16
|
+
areaId: assertId(segments[0], "area ID"),
|
|
17
|
+
resourceType: "area",
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
if (segments.length !== 3)
|
|
21
|
+
throw new Error("Unsupported brain URI form");
|
|
22
|
+
const areaId = assertId(segments[0], "area ID");
|
|
23
|
+
const resourceType = segments[1];
|
|
24
|
+
if (resourceType !== "note" && resourceType !== "source") {
|
|
25
|
+
throw new Error("Unsupported brain resource type");
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
areaId,
|
|
29
|
+
resourceType,
|
|
30
|
+
resourceId: assertId(segments[2], `${resourceType} ID`),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function parseBrainResource(input) {
|
|
34
|
+
if (input.length > 512)
|
|
35
|
+
throw new Error("Brain URI is too long");
|
|
36
|
+
let url;
|
|
37
|
+
try {
|
|
38
|
+
url = new URL(input);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
throw new Error("Invalid brain URI");
|
|
42
|
+
}
|
|
43
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
44
|
+
throw new Error("Brain URIs cannot contain credentials, queries, or fragments");
|
|
45
|
+
}
|
|
46
|
+
let pathname;
|
|
47
|
+
if (url.protocol === "dayofweek:") {
|
|
48
|
+
if (url.hostname !== "brain" || url.port)
|
|
49
|
+
throw new Error("Invalid brain URI authority");
|
|
50
|
+
pathname = url.pathname;
|
|
51
|
+
}
|
|
52
|
+
else if (url.protocol === "https:") {
|
|
53
|
+
if (url.origin !== HTTPS_ORIGIN || !url.pathname.startsWith(HTTPS_PREFIX)) {
|
|
54
|
+
throw new Error("Unsupported Day of Week deeplink");
|
|
55
|
+
}
|
|
56
|
+
pathname = url.pathname.slice(HTTPS_PREFIX.length - 1);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
throw new Error("Unsupported brain URI protocol");
|
|
60
|
+
}
|
|
61
|
+
return { version: 1, ...parseSegments(pathname) };
|
|
62
|
+
}
|
|
63
|
+
export function toBrainUri(resource) {
|
|
64
|
+
const areaId = assertId(resource.areaId, "area ID");
|
|
65
|
+
if (resource.resourceType === "area")
|
|
66
|
+
return `dayofweek://brain/${areaId}`;
|
|
67
|
+
const resourceId = assertId(resource.resourceId, `${resource.resourceType} ID`);
|
|
68
|
+
return `dayofweek://brain/${areaId}/${resource.resourceType}/${resourceId}`;
|
|
69
|
+
}
|
|
70
|
+
export function toBrainHttpsUrl(resource) {
|
|
71
|
+
return `${HTTPS_ORIGIN}/app/brain/${toBrainUri(resource).slice("dayofweek://brain/".length)}`;
|
|
72
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dayofweek/dcli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "CLI for the Day of Week AgTech platform — read data and submit proposals for review",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
"scripts": {
|
|
17
17
|
"build": "tsc",
|
|
18
18
|
"dev": "tsx src/bin/dcli.ts",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"test:watch": "vitest",
|
|
21
|
+
"standalone:build": "node scripts/build-standalone.mjs",
|
|
22
|
+
"standalone:smoke": "node scripts/smoke-standalone.mjs",
|
|
19
23
|
"prepublishOnly": "npm run build"
|
|
20
24
|
},
|
|
21
25
|
"dependencies": {
|
|
@@ -23,9 +27,12 @@
|
|
|
23
27
|
"open": "^11.0.0"
|
|
24
28
|
},
|
|
25
29
|
"devDependencies": {
|
|
30
|
+
"@types/node": "^22.0.0",
|
|
31
|
+
"esbuild": "^0.25.0",
|
|
32
|
+
"postject": "^1.0.0-alpha.6",
|
|
26
33
|
"tsx": "^4.21.0",
|
|
27
34
|
"typescript": "^5.9.0",
|
|
28
|
-
"
|
|
35
|
+
"vitest": "^4.0.0"
|
|
29
36
|
},
|
|
30
37
|
"publishConfig": {
|
|
31
38
|
"access": "public"
|