@skillcdn/console 0.1.18 → 0.1.19
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 +1 -1
- package/SKILLCDN.md +1 -1
- package/dist/api.d.ts +1 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +1 -1
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +353 -39
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/credentials.d.ts +102 -3
- package/dist/cli/credentials.d.ts.map +1 -1
- package/dist/cli/credentials.js +177 -8
- package/dist/cli/credentials.js.map +1 -1
- package/dist/cli/directory.d.ts +6 -0
- package/dist/cli/directory.d.ts.map +1 -1
- package/dist/cli/directory.js +37 -6
- package/dist/cli/directory.js.map +1 -1
- package/dist/cli/format.d.ts +10 -1
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +106 -2
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +42 -20
- package/dist/cli/help.js.map +1 -1
- package/dist/client.d.ts +21 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +14 -1
- package/dist/client.js.map +1 -1
- package/dist/components/board.d.ts +9 -1
- package/dist/components/board.d.ts.map +1 -1
- package/dist/components/board.js +82 -5
- package/dist/components/board.js.map +1 -1
- package/dist/components/comments.d.ts.map +1 -1
- package/dist/components/comments.js +12 -4
- package/dist/components/comments.js.map +1 -1
- package/dist/components/event-feed.d.ts.map +1 -1
- package/dist/components/event-feed.js +4 -0
- package/dist/components/event-feed.js.map +1 -1
- package/dist/components/goals.d.ts +17 -1
- package/dist/components/goals.d.ts.map +1 -1
- package/dist/components/goals.js +34 -9
- package/dist/components/goals.js.map +1 -1
- package/dist/components/markdown.d.ts +6 -0
- package/dist/components/markdown.d.ts.map +1 -1
- package/dist/components/markdown.js +7 -0
- package/dist/components/markdown.js.map +1 -1
- package/dist/components/pictures.d.ts +68 -0
- package/dist/components/pictures.d.ts.map +1 -0
- package/dist/components/pictures.js +213 -0
- package/dist/components/pictures.js.map +1 -0
- package/dist/components/projects.d.ts +12 -0
- package/dist/components/projects.d.ts.map +1 -1
- package/dist/components/projects.js +15 -0
- package/dist/components/projects.js.map +1 -1
- package/dist/components/task-view.d.ts +3 -0
- package/dist/components/task-view.d.ts.map +1 -1
- package/dist/components/task-view.js +6 -4
- package/dist/components/task-view.js.map +1 -1
- package/dist/console.css +282 -3
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +124 -10
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +16 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +47 -2
- package/dist/data.js.map +1 -1
- package/dist/i18n/en.d.ts +52 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +50 -2
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +50 -2
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -4
- package/dist/index.js.map +1 -1
- package/dist/router.d.ts +25 -6
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +38 -7
- package/dist/router.js.map +1 -1
- package/dist/rules.js +5 -5
- package/dist/rules.js.map +1 -1
- package/dist/schemas.d.ts +158 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +78 -0
- package/dist/schemas.js.map +1 -1
- package/dist/vocabulary.d.ts +2 -2
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +3 -0
- package/dist/vocabulary.js.map +1 -1
- package/docs/specs/cli.md +17 -9
- package/docs/specs/rest.md +22 -4
- package/package.json +1 -1
- package/skills/working-the-board/SKILL.md +10 -4
- package/src/api.ts +13 -0
- package/src/cli/cli.ts +441 -42
- package/src/cli/credentials.ts +276 -14
- package/src/cli/directory.ts +43 -6
- package/src/cli/format.ts +138 -2
- package/src/cli/help.ts +44 -20
- package/src/client.ts +60 -0
- package/src/components/board.tsx +154 -8
- package/src/components/comments.tsx +20 -10
- package/src/components/event-feed.tsx +4 -0
- package/src/components/goals.tsx +173 -106
- package/src/components/markdown.tsx +19 -0
- package/src/components/pictures.tsx +389 -0
- package/src/components/projects.tsx +68 -0
- package/src/components/task-view.tsx +43 -2
- package/src/console.tsx +259 -4
- package/src/data.ts +72 -1
- package/src/i18n/en.ts +53 -2
- package/src/i18n/ko.ts +53 -2
- package/src/index.ts +27 -0
- package/src/router.ts +57 -9
- package/src/rules.ts +5 -5
- package/src/schemas.ts +97 -0
- package/src/styles/console.css +282 -3
- package/src/vocabulary.ts +3 -0
package/src/cli/credentials.ts
CHANGED
|
@@ -1,31 +1,270 @@
|
|
|
1
|
-
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
1
|
+
import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
4
5
|
import * as z from "zod/mini";
|
|
5
6
|
|
|
6
|
-
// Where the command keeps what `login` was given:
|
|
7
|
-
//
|
|
8
|
-
//
|
|
7
|
+
// Where the command keeps what `login` was given (ADR-0027): for every console a person signed
|
|
8
|
+
// it in to, the console's address, the account there and the token, in the person's own
|
|
9
|
+
// configuration directory, readable by them alone where the system can say so. A token is a
|
|
10
|
+
// secret the person made; this file is the one place it rests, and a token goes only to the
|
|
11
|
+
// console that issued it: the command resolves which console a command talks to first, and then
|
|
12
|
+
// looks for a login there, never the other way round.
|
|
9
13
|
|
|
10
|
-
|
|
14
|
+
/** One sign-in: a console, an account there, and the token the console handed over. */
|
|
15
|
+
export interface Login {
|
|
11
16
|
/** The origin of the console, without a path. */
|
|
12
17
|
readonly url: string;
|
|
13
|
-
/**
|
|
18
|
+
/** The person's login at the console, as the console named them; unknown for a login kept before accounts were. */
|
|
19
|
+
readonly account: string | undefined;
|
|
14
20
|
readonly token: string;
|
|
21
|
+
/** What the token is called at the console, when known. */
|
|
22
|
+
readonly agent: string | undefined;
|
|
23
|
+
/** When the command was signed in, when known. */
|
|
24
|
+
readonly since: string | undefined;
|
|
15
25
|
}
|
|
16
26
|
|
|
27
|
+
/** Everything the command keeps: the logins, and the console a directory that names none works at. */
|
|
28
|
+
export interface Credentials {
|
|
29
|
+
readonly default: string | undefined;
|
|
30
|
+
readonly logins: readonly Login[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const EMPTY_CREDENTIALS: Credentials = { default: undefined, logins: [] };
|
|
34
|
+
|
|
35
|
+
/** How many consoles one machine may be signed in to: far more than anyone has. */
|
|
36
|
+
export const MAX_LOGINS = 100;
|
|
37
|
+
/** How large the file may be before it is not read: far above a hundred logins. */
|
|
38
|
+
const MAX_FILE_BYTES = 64 * 1024;
|
|
39
|
+
|
|
17
40
|
export interface CredentialStore {
|
|
41
|
+
/**
|
|
42
|
+
* What is kept, or nothing. A file that is there and cannot be read is a `CredentialsError`,
|
|
43
|
+
* so that the command stops and never writes over what it did not understand.
|
|
44
|
+
*/
|
|
18
45
|
load(): Promise<Credentials | undefined>;
|
|
19
|
-
/** Keeps the credentials and answers where. */
|
|
46
|
+
/** Keeps the credentials, whole, and answers where. */
|
|
20
47
|
save(credentials: Credentials): Promise<string>;
|
|
48
|
+
/** Forgets everything kept. */
|
|
21
49
|
clear(): Promise<void>;
|
|
22
50
|
}
|
|
23
51
|
|
|
24
|
-
|
|
52
|
+
export class CredentialsError extends Error {
|
|
53
|
+
readonly code: "credentials.malformed" | "credentials.too_large";
|
|
54
|
+
readonly path: string;
|
|
55
|
+
|
|
56
|
+
constructor(code: CredentialsError["code"], path: string) {
|
|
57
|
+
super(
|
|
58
|
+
code === "credentials.too_large"
|
|
59
|
+
? `The credentials file ${path} is larger than the command reads.`
|
|
60
|
+
: `The credentials file ${path} could not be read as one.`,
|
|
61
|
+
);
|
|
62
|
+
this.name = "CredentialsError";
|
|
63
|
+
this.code = code;
|
|
64
|
+
this.path = path;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const loginSchema = z.object({
|
|
69
|
+
url: z.string().check(z.minLength(1), z.maxLength(2048)),
|
|
70
|
+
account: z.optional(z.string().check(z.minLength(1), z.maxLength(254))),
|
|
71
|
+
token: z.string().check(z.minLength(1), z.maxLength(1024)),
|
|
72
|
+
agent: z.optional(z.string().check(z.maxLength(200))),
|
|
73
|
+
since: z.optional(z.string().check(z.maxLength(40))),
|
|
74
|
+
});
|
|
75
|
+
const fileSchema = z.object({
|
|
76
|
+
version: z.literal(2),
|
|
77
|
+
default: z.optional(z.string().check(z.minLength(1), z.maxLength(2048))),
|
|
78
|
+
logins: z.array(loginSchema).check(z.maxLength(MAX_LOGINS)),
|
|
79
|
+
});
|
|
80
|
+
/** The file as the command kept it before 2026-10: one console, one token. */
|
|
81
|
+
const firstFileSchema = z.object({
|
|
25
82
|
url: z.string().check(z.minLength(1)),
|
|
26
83
|
token: z.string().check(z.minLength(1)),
|
|
27
84
|
});
|
|
28
85
|
|
|
86
|
+
/** What a file says, in the current shape; one in the first shape reads as one login, the default. Nothing for anything else. */
|
|
87
|
+
export function parseCredentials(json: unknown): Credentials | undefined {
|
|
88
|
+
const current = fileSchema.safeParse(json);
|
|
89
|
+
if (current.success) {
|
|
90
|
+
return {
|
|
91
|
+
default: current.data.default,
|
|
92
|
+
logins: current.data.logins.map((login) => ({
|
|
93
|
+
url: login.url,
|
|
94
|
+
account: login.account,
|
|
95
|
+
token: login.token,
|
|
96
|
+
agent: login.agent,
|
|
97
|
+
since: login.since,
|
|
98
|
+
})),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const first = firstFileSchema.safeParse(json);
|
|
102
|
+
if (first.success) {
|
|
103
|
+
return {
|
|
104
|
+
default: first.data.url,
|
|
105
|
+
logins: [
|
|
106
|
+
{
|
|
107
|
+
url: first.data.url,
|
|
108
|
+
account: undefined,
|
|
109
|
+
token: first.data.token,
|
|
110
|
+
agent: undefined,
|
|
111
|
+
since: undefined,
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The logins at one console. */
|
|
120
|
+
export function loginsAt(credentials: Credentials, url: string): readonly Login[] {
|
|
121
|
+
return credentials.logins.filter((login) => login.url === url);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The consoles signed in to, each once, in the order they were signed in to. */
|
|
125
|
+
export function consolesOf(credentials: Credentials): readonly string[] {
|
|
126
|
+
return [...new Set(credentials.logins.map((login) => login.url))];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* With a login kept: in place of the one at the same console for the same account, else added.
|
|
131
|
+
* The first login kept is the default console, and one kept as the default is.
|
|
132
|
+
*/
|
|
133
|
+
export function withLogin(
|
|
134
|
+
credentials: Credentials,
|
|
135
|
+
login: Login,
|
|
136
|
+
options: { readonly default?: boolean | undefined } = {},
|
|
137
|
+
): Credentials {
|
|
138
|
+
const rest = credentials.logins.filter(
|
|
139
|
+
(kept) => !(kept.url === login.url && kept.account === login.account),
|
|
140
|
+
);
|
|
141
|
+
const first = credentials.logins.length === 0 ? login.url : undefined;
|
|
142
|
+
return {
|
|
143
|
+
default: options.default === true ? login.url : (credentials.default ?? first),
|
|
144
|
+
logins: [...rest, login],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Without the login at a console, for one account or for every account there. The default
|
|
150
|
+
* stays while a login at it remains; else it moves to the one console left, or to none.
|
|
151
|
+
*/
|
|
152
|
+
export function withoutLogin(credentials: Credentials, url: string, account?: string): Credentials {
|
|
153
|
+
const logins = credentials.logins.filter(
|
|
154
|
+
(kept) => !(kept.url === url && (account === undefined || kept.account === account)),
|
|
155
|
+
);
|
|
156
|
+
const consoles = [...new Set(logins.map((kept) => kept.url))];
|
|
157
|
+
const [only] = consoles;
|
|
158
|
+
const fallback = consoles.length === 1 ? only : undefined;
|
|
159
|
+
return {
|
|
160
|
+
default:
|
|
161
|
+
credentials.default !== undefined && consoles.includes(credentials.default)
|
|
162
|
+
? credentials.default
|
|
163
|
+
: fallback,
|
|
164
|
+
logins,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Where a command is told which console to talk to, in the order the first one found wins. */
|
|
169
|
+
export type AddressSource = "flag" | "environment" | "directory" | "default";
|
|
170
|
+
|
|
171
|
+
export interface Lookup {
|
|
172
|
+
/** `--url`, as an origin. */
|
|
173
|
+
readonly flagUrl?: string | undefined;
|
|
174
|
+
/** `--account`. */
|
|
175
|
+
readonly flagAccount?: string | undefined;
|
|
176
|
+
/** `CONSOLE_URL`, as an origin, and `CONSOLE_TOKEN`: a pair, for a hook or a job. */
|
|
177
|
+
readonly envUrl?: string | undefined;
|
|
178
|
+
readonly envToken?: string | undefined;
|
|
179
|
+
/** What `.skillcdn-console.json` says of the console and the account, when it does. */
|
|
180
|
+
readonly directoryUrl?: string | undefined;
|
|
181
|
+
readonly directoryAccount?: string | undefined;
|
|
182
|
+
readonly credentials: Credentials | undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export type Resolution =
|
|
186
|
+
| {
|
|
187
|
+
readonly ok: true;
|
|
188
|
+
readonly url: string;
|
|
189
|
+
readonly token: string;
|
|
190
|
+
readonly account: string | undefined;
|
|
191
|
+
readonly source: AddressSource;
|
|
192
|
+
/** Where the token came from: the environment's pair, or a login kept here. */
|
|
193
|
+
readonly via: "environment" | "login";
|
|
194
|
+
}
|
|
195
|
+
/** Nothing names a console and nothing is kept. */
|
|
196
|
+
| { readonly ok: false; readonly reason: "nothing" }
|
|
197
|
+
/** One half of the environment's pair is set. */
|
|
198
|
+
| {
|
|
199
|
+
readonly ok: false;
|
|
200
|
+
readonly reason: "environment_half";
|
|
201
|
+
readonly have: "CONSOLE_URL" | "CONSOLE_TOKEN";
|
|
202
|
+
}
|
|
203
|
+
/** A console is named, and nothing here is signed in to it, as that account where one is named. */
|
|
204
|
+
| {
|
|
205
|
+
readonly ok: false;
|
|
206
|
+
readonly reason: "no_login";
|
|
207
|
+
readonly url: string;
|
|
208
|
+
readonly account: string | undefined;
|
|
209
|
+
readonly source: AddressSource;
|
|
210
|
+
}
|
|
211
|
+
/** Several accounts are signed in at the console, and nothing says which. */
|
|
212
|
+
| {
|
|
213
|
+
readonly ok: false;
|
|
214
|
+
readonly reason: "ambiguous";
|
|
215
|
+
readonly url: string;
|
|
216
|
+
readonly accounts: readonly string[];
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Which console a command talks to, and with what (ADR-0027). The address comes first: the
|
|
221
|
+
* flag, else the environment, else the directory, else the default login. The token is then
|
|
222
|
+
* the one for that address and no other: the environment's token where its address is that
|
|
223
|
+
* one, else the login kept for it, for the account named or the one account there.
|
|
224
|
+
*/
|
|
225
|
+
export function resolveLogin(lookup: Lookup): Resolution {
|
|
226
|
+
const { envUrl, envToken } = lookup;
|
|
227
|
+
if ((envUrl === undefined) !== (envToken === undefined)) {
|
|
228
|
+
return {
|
|
229
|
+
ok: false,
|
|
230
|
+
reason: "environment_half",
|
|
231
|
+
have: envUrl === undefined ? "CONSOLE_TOKEN" : "CONSOLE_URL",
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const credentials = lookup.credentials ?? EMPTY_CREDENTIALS;
|
|
235
|
+
const consoles = consolesOf(credentials);
|
|
236
|
+
const [source, url]: [AddressSource, string | undefined] =
|
|
237
|
+
lookup.flagUrl !== undefined
|
|
238
|
+
? ["flag", lookup.flagUrl]
|
|
239
|
+
: envUrl !== undefined
|
|
240
|
+
? ["environment", envUrl]
|
|
241
|
+
: lookup.directoryUrl !== undefined
|
|
242
|
+
? ["directory", lookup.directoryUrl]
|
|
243
|
+
: ["default", credentials.default ?? (consoles.length === 1 ? consoles[0] : undefined)];
|
|
244
|
+
if (url === undefined) {
|
|
245
|
+
return { ok: false, reason: "nothing" };
|
|
246
|
+
}
|
|
247
|
+
const account = lookup.flagAccount ?? lookup.directoryAccount;
|
|
248
|
+
if (envUrl !== undefined && envToken !== undefined && envUrl === url) {
|
|
249
|
+
return { ok: true, url, token: envToken, account, source, via: "environment" };
|
|
250
|
+
}
|
|
251
|
+
const at = loginsAt(credentials, url);
|
|
252
|
+
const chosen = account === undefined ? at : at.filter((login) => login.account === account);
|
|
253
|
+
const [login] = chosen;
|
|
254
|
+
if (login === undefined) {
|
|
255
|
+
return { ok: false, reason: "no_login", url, account, source };
|
|
256
|
+
}
|
|
257
|
+
if (chosen.length > 1) {
|
|
258
|
+
return {
|
|
259
|
+
ok: false,
|
|
260
|
+
reason: "ambiguous",
|
|
261
|
+
url,
|
|
262
|
+
accounts: chosen.map((kept) => kept.account ?? "?"),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return { ok: true, url, token: login.token, account: login.account, source, via: "login" };
|
|
266
|
+
}
|
|
267
|
+
|
|
29
268
|
const FILE_NAME = "credentials.json";
|
|
30
269
|
|
|
31
270
|
/** The directory the file lives in: under `XDG_CONFIG_HOME`, or `~/.config`. */
|
|
@@ -41,22 +280,45 @@ export function createFileStore(directory: string): CredentialStore {
|
|
|
41
280
|
const path = join(directory, FILE_NAME);
|
|
42
281
|
return {
|
|
43
282
|
async load() {
|
|
44
|
-
let
|
|
283
|
+
let size: number;
|
|
45
284
|
try {
|
|
46
|
-
|
|
285
|
+
size = (await stat(path)).size;
|
|
47
286
|
} catch {
|
|
48
287
|
return undefined;
|
|
49
288
|
}
|
|
289
|
+
if (size > MAX_FILE_BYTES) {
|
|
290
|
+
throw new CredentialsError("credentials.too_large", path);
|
|
291
|
+
}
|
|
292
|
+
let json: unknown;
|
|
50
293
|
try {
|
|
51
|
-
|
|
52
|
-
return parsed.success ? parsed.data : undefined;
|
|
294
|
+
json = JSON.parse(await readFile(path, "utf8"));
|
|
53
295
|
} catch {
|
|
54
|
-
|
|
296
|
+
throw new CredentialsError("credentials.malformed", path);
|
|
297
|
+
}
|
|
298
|
+
const parsed = parseCredentials(json);
|
|
299
|
+
if (parsed === undefined) {
|
|
300
|
+
throw new CredentialsError("credentials.malformed", path);
|
|
55
301
|
}
|
|
302
|
+
return parsed;
|
|
56
303
|
},
|
|
57
304
|
async save(credentials) {
|
|
58
305
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
59
|
-
|
|
306
|
+
const file = {
|
|
307
|
+
version: 2,
|
|
308
|
+
default: credentials.default,
|
|
309
|
+
logins: credentials.logins.map((login) => ({
|
|
310
|
+
url: login.url,
|
|
311
|
+
account: login.account,
|
|
312
|
+
token: login.token,
|
|
313
|
+
agent: login.agent,
|
|
314
|
+
since: login.since,
|
|
315
|
+
})),
|
|
316
|
+
};
|
|
317
|
+
// Written beside the file and moved over it in one step, so that a command that stops
|
|
318
|
+
// half-way leaves the file as it was, whole.
|
|
319
|
+
const draft = `${path}.${process.pid}.tmp`;
|
|
320
|
+
await writeFile(draft, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
|
|
321
|
+
await rename(draft, path);
|
|
60
322
|
// The mode above applies to a file made now; one that was there keeps its own, so it is set again.
|
|
61
323
|
await chmod(path, 0o600).catch(() => undefined);
|
|
62
324
|
return path;
|
package/src/cli/directory.ts
CHANGED
|
@@ -4,15 +4,27 @@ import * as z from "zod/mini";
|
|
|
4
4
|
|
|
5
5
|
// What a working directory says about the project it belongs to: `.skillcdn-console.json` in
|
|
6
6
|
// it or in a directory above it, as `.nvmrc` and its kind are found. Written by `console use`,
|
|
7
|
-
// and meant to be committed, so that everyone who works in the
|
|
8
|
-
// run in it, is in the same project without saying so
|
|
7
|
+
// and meant to be committed in a private repository, so that everyone who works in the
|
|
8
|
+
// checkout, and every agent they run in it, is in the same project without saying so; left out
|
|
9
|
+
// of a public one, since it may name the organization's console. It names the project, and
|
|
10
|
+
// where the checkout is bound to a console other than the default login, that console and the
|
|
11
|
+
// account there (ADR-0027). Never a token, a code or anything secret: the token for the console
|
|
12
|
+
// it names is looked up in the credentials the command keeps, and a key this file does not
|
|
13
|
+
// know is dropped on reading.
|
|
9
14
|
|
|
10
15
|
/** The file's name, in the working directory or one above it. */
|
|
11
16
|
export const DIRECTORY_FILE = ".skillcdn-console.json";
|
|
12
17
|
|
|
18
|
+
/** How large the file may be before it is not read: a few names, never more. */
|
|
19
|
+
const MAX_FILE_CHARS = 16 * 1024;
|
|
20
|
+
|
|
13
21
|
export interface DirectorySettings {
|
|
14
22
|
/** The key of the project the directory's work belongs to. */
|
|
15
23
|
readonly project: string;
|
|
24
|
+
/** The origin of the console the directory is bound to; the default login when left out. */
|
|
25
|
+
readonly url?: string | undefined;
|
|
26
|
+
/** The account at that console, where several are signed in to it. */
|
|
27
|
+
readonly account?: string | undefined;
|
|
16
28
|
}
|
|
17
29
|
|
|
18
30
|
export interface DirectoryStore {
|
|
@@ -22,7 +34,24 @@ export interface DirectoryStore {
|
|
|
22
34
|
save(settings: DirectorySettings): Promise<string>;
|
|
23
35
|
}
|
|
24
36
|
|
|
25
|
-
const settingsSchema = z.object({
|
|
37
|
+
const settingsSchema = z.object({
|
|
38
|
+
project: z.string().check(z.minLength(1), z.maxLength(100)),
|
|
39
|
+
url: z.optional(z.string().check(z.minLength(1), z.maxLength(2048))),
|
|
40
|
+
account: z.optional(z.string().check(z.minLength(1), z.maxLength(254))),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** What a file says, with nothing but the three settings kept; nothing for anything else. */
|
|
44
|
+
export function parseDirectorySettings(json: unknown): DirectorySettings | undefined {
|
|
45
|
+
const parsed = settingsSchema.safeParse(json);
|
|
46
|
+
if (!parsed.success) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
project: parsed.data.project,
|
|
51
|
+
...(parsed.data.url === undefined ? {} : { url: parsed.data.url }),
|
|
52
|
+
...(parsed.data.account === undefined ? {} : { account: parsed.data.account }),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
26
55
|
|
|
27
56
|
export function createDirectoryStore(cwd: string): DirectoryStore {
|
|
28
57
|
return {
|
|
@@ -36,9 +65,11 @@ export function createDirectoryStore(cwd: string): DirectoryStore {
|
|
|
36
65
|
content = undefined;
|
|
37
66
|
}
|
|
38
67
|
if (content !== undefined) {
|
|
68
|
+
if (content.length > MAX_FILE_CHARS) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
39
71
|
try {
|
|
40
|
-
|
|
41
|
-
return parsed.success ? parsed.data : undefined;
|
|
72
|
+
return parseDirectorySettings(JSON.parse(content));
|
|
42
73
|
} catch {
|
|
43
74
|
return undefined;
|
|
44
75
|
}
|
|
@@ -52,7 +83,13 @@ export function createDirectoryStore(cwd: string): DirectoryStore {
|
|
|
52
83
|
},
|
|
53
84
|
async save(settings) {
|
|
54
85
|
const path = join(cwd, DIRECTORY_FILE);
|
|
55
|
-
|
|
86
|
+
// The three settings, named one by one: nothing else ever reaches this file.
|
|
87
|
+
const written = {
|
|
88
|
+
project: settings.project,
|
|
89
|
+
...(settings.url === undefined ? {} : { url: settings.url }),
|
|
90
|
+
...(settings.account === undefined ? {} : { account: settings.account }),
|
|
91
|
+
};
|
|
92
|
+
await writeFile(path, `${JSON.stringify(written, null, 2)}\n`);
|
|
56
93
|
return path;
|
|
57
94
|
},
|
|
58
95
|
};
|
package/src/cli/format.ts
CHANGED
|
@@ -15,6 +15,9 @@ import type {
|
|
|
15
15
|
RestProjectRules,
|
|
16
16
|
RestRules,
|
|
17
17
|
RestRun,
|
|
18
|
+
RestSkillFile,
|
|
19
|
+
RestSkillLoad,
|
|
20
|
+
RestSkillPage,
|
|
18
21
|
RestSkills,
|
|
19
22
|
RestTask,
|
|
20
23
|
RestVersion,
|
|
@@ -55,6 +58,7 @@ export function formatProject(project: RestProject): string {
|
|
|
55
58
|
`project ${project.key}: ${project.name}`,
|
|
56
59
|
`visibility: ${project.visibility} role: ${project.role} skills: ${project.skillsAddress ?? "the organization's"}`,
|
|
57
60
|
`color: ${project.color ?? "none"} symbol: ${project.symbol ?? "none"} image: ${project.icon === null ? "none" : project.icon.contentType}`,
|
|
61
|
+
`pack token: ${project.packToken === null ? "none" : `set ${project.packToken.setAt}`}`,
|
|
58
62
|
];
|
|
59
63
|
if (project.description.trim().length > 0) {
|
|
60
64
|
lines.push("", project.description.trimEnd());
|
|
@@ -102,6 +106,9 @@ export function formatTaskLine(task: RestTask): string {
|
|
|
102
106
|
if (task.openDecisions > 0) {
|
|
103
107
|
notes.push(`${plural(task.openDecisions, "decision")} waiting`);
|
|
104
108
|
}
|
|
109
|
+
if (task.archivedAt !== null) {
|
|
110
|
+
notes.push("archived");
|
|
111
|
+
}
|
|
105
112
|
return `#${task.number} ${task.state} ${task.priority} ${task.title} (${notes.join("; ")})`;
|
|
106
113
|
}
|
|
107
114
|
|
|
@@ -120,6 +127,7 @@ function scheduleLines(task: RestTask): string[] {
|
|
|
120
127
|
const happened = [
|
|
121
128
|
task.startedAt === null ? undefined : `started: ${task.startedAt}`,
|
|
122
129
|
task.doneAt === null ? undefined : `done: ${task.doneAt}`,
|
|
130
|
+
task.archivedAt === null ? undefined : `archived: ${task.archivedAt}`,
|
|
123
131
|
].filter((part) => part !== undefined);
|
|
124
132
|
if (happened.length > 0) {
|
|
125
133
|
lines.push(happened.join(" "));
|
|
@@ -239,7 +247,10 @@ export function formatArtifact(
|
|
|
239
247
|
const SKILLS_WORDS: Readonly<Record<Exclude<RestSkills["status"], "none" | "ready">, string>> = {
|
|
240
248
|
indexing: "SkillCDN is still indexing the repository; ask again in a moment.",
|
|
241
249
|
failed: "SkillCDN could not index the repository.",
|
|
242
|
-
not_found:
|
|
250
|
+
not_found:
|
|
251
|
+
"SkillCDN does not serve this address to the console: nothing is there, or the pack is private and the project holds no pack token for it (console project pack-token <key> --stdin, for an owner).",
|
|
252
|
+
token_invalid:
|
|
253
|
+
"The pack token the project holds no longer works at SkillCDN: an owner sets a new one (console project pack-token <key> --stdin).",
|
|
243
254
|
unavailable: "SkillCDN could not be reached; ask again later.",
|
|
244
255
|
};
|
|
245
256
|
|
|
@@ -271,7 +282,7 @@ export function formatSkills(skills: RestSkills, ownSkills?: string): string {
|
|
|
271
282
|
}
|
|
272
283
|
lines.push(
|
|
273
284
|
"",
|
|
274
|
-
`
|
|
285
|
+
`Read a skill whole with console skill <path>, its rules first, and a file of the pack with console skill <path> --file <file>; or load it by its URI through your own SkillCDN connection to this address, which a person makes at ${skills.page}. --json adds the page of each, for a person.`,
|
|
275
286
|
...own,
|
|
276
287
|
);
|
|
277
288
|
return lines.join("\n");
|
|
@@ -495,3 +506,128 @@ export function formatVersion(path: string, version: RestVersion): string {
|
|
|
495
506
|
}
|
|
496
507
|
return lines.join("\n");
|
|
497
508
|
}
|
|
509
|
+
|
|
510
|
+
/** The text of one path across the pages a skill came in: the fragments joined in order. */
|
|
511
|
+
function joinTexts(
|
|
512
|
+
pages: readonly RestSkillPage[],
|
|
513
|
+
pick: (page: RestSkillPage) => readonly { readonly path: string; readonly body: string }[],
|
|
514
|
+
): { readonly path: string; readonly body: string }[] {
|
|
515
|
+
const joined = new Map<string, string>();
|
|
516
|
+
for (const page of pages) {
|
|
517
|
+
for (const text of pick(page)) {
|
|
518
|
+
joined.set(text.path, (joined.get(text.path) ?? "") + text.body);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return [...joined.entries()].map(([path, body]) => ({ path, body }));
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const NO_ADDRESS = "No skills address: neither this project nor the console names one.";
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* One skill, as load_skill lays it out for a model (ADR-0028): the rules from the repository's
|
|
528
|
+
* root to the nearest manifest, the body, the included files, each under a line that names its
|
|
529
|
+
* path; then the files the skill has and what it refers to. A skill described only is named and
|
|
530
|
+
* pointed at, and nothing of its body is shown.
|
|
531
|
+
*/
|
|
532
|
+
export function formatSkillLoad(loaded: RestSkillLoad, pages: readonly RestSkillPage[]): string {
|
|
533
|
+
if (loaded.status === "none" || loaded.address === null) {
|
|
534
|
+
return NO_ADDRESS;
|
|
535
|
+
}
|
|
536
|
+
const head = `skill at ${loaded.address}, served by ${loaded.source}: ${loaded.status}`;
|
|
537
|
+
if (loaded.status !== "ready") {
|
|
538
|
+
return [head, SKILLS_WORDS[loaded.status]].join("\n");
|
|
539
|
+
}
|
|
540
|
+
const [first] = pages;
|
|
541
|
+
if (first === undefined) {
|
|
542
|
+
return [head, "Nothing was read."].join("\n");
|
|
543
|
+
}
|
|
544
|
+
const last = pages[pages.length - 1] ?? first;
|
|
545
|
+
const lines = [
|
|
546
|
+
`${first.name} ${first.description}`,
|
|
547
|
+
`skill ${first.path} at ${loaded.address}, served by ${loaded.source}`,
|
|
548
|
+
];
|
|
549
|
+
if (first.serving.license !== null) {
|
|
550
|
+
lines.push(`license: ${first.serving.license}`);
|
|
551
|
+
}
|
|
552
|
+
if (!first.serving.full) {
|
|
553
|
+
lines.push(
|
|
554
|
+
"",
|
|
555
|
+
`Described only: the license keeps the skill at its source. Read it there: ${first.serving.sourceUrl ?? loaded.page ?? loaded.source}`,
|
|
556
|
+
);
|
|
557
|
+
return lines.join("\n");
|
|
558
|
+
}
|
|
559
|
+
for (const rule of joinTexts(pages, (page) => page.rules)) {
|
|
560
|
+
lines.push("", `--- applicable rules: ${rule.path} ---`, rule.body.trimEnd());
|
|
561
|
+
}
|
|
562
|
+
lines.push(
|
|
563
|
+
"",
|
|
564
|
+
`--- skill: ${first.path} ---`,
|
|
565
|
+
pages
|
|
566
|
+
.map((page) => page.body)
|
|
567
|
+
.join("")
|
|
568
|
+
.trimEnd(),
|
|
569
|
+
);
|
|
570
|
+
for (const file of joinTexts(pages, (page) => page.included)) {
|
|
571
|
+
lines.push("", `--- included file: ${file.path} ---`, file.body.trimEnd());
|
|
572
|
+
}
|
|
573
|
+
if (!last.complete) {
|
|
574
|
+
lines.push("", "The skill did not come whole: the deployment stopped before the end.");
|
|
575
|
+
}
|
|
576
|
+
if (last.files.length > 0) {
|
|
577
|
+
lines.push(
|
|
578
|
+
"",
|
|
579
|
+
`Files of the skill, read with console skill ${first.path} --file <path>: ${last.files.join(", ")}`,
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
if (last.references.length > 0) {
|
|
583
|
+
lines.push(
|
|
584
|
+
`References: ${last.references
|
|
585
|
+
.map((reference) =>
|
|
586
|
+
reference.status === "available"
|
|
587
|
+
? reference.path
|
|
588
|
+
: `${reference.path} (${reference.status})`,
|
|
589
|
+
)
|
|
590
|
+
.join(", ")}`,
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
if (last.warnings.length > 0) {
|
|
594
|
+
lines.push(`Warnings: ${last.warnings.join("; ")}`);
|
|
595
|
+
}
|
|
596
|
+
lines.push(
|
|
597
|
+
"",
|
|
598
|
+
"Paths from the repository's root are the only identifiers. The content is the pack's, under its license; what a private pack says stays in your work.",
|
|
599
|
+
);
|
|
600
|
+
return lines.join("\n");
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** A file of the pack as read: its text whole, or what the folder holds. */
|
|
604
|
+
export function formatSkillFile(read: RestSkillFile, content: string): string {
|
|
605
|
+
if (read.status === "none" || read.address === null) {
|
|
606
|
+
return NO_ADDRESS;
|
|
607
|
+
}
|
|
608
|
+
const head = `file at ${read.address}, served by ${read.source}: ${read.status}`;
|
|
609
|
+
if (read.status !== "ready") {
|
|
610
|
+
return [
|
|
611
|
+
head,
|
|
612
|
+
read.status === "failed"
|
|
613
|
+
? "SkillCDN will not carry this file: it is too large, or not text. Read it at the repository."
|
|
614
|
+
: SKILLS_WORDS[read.status],
|
|
615
|
+
].join("\n");
|
|
616
|
+
}
|
|
617
|
+
if (read.folder !== null) {
|
|
618
|
+
return [
|
|
619
|
+
`folder ${read.folder.path} at ${read.address}${read.folder.truncated ? " (cut)" : ""}`,
|
|
620
|
+
...read.folder.entries.map(
|
|
621
|
+
(entry) =>
|
|
622
|
+
`${entry.kind === "directory" ? "d" : "f"} ${entry.path}${entry.size === null ? "" : ` ${entry.size}`}`,
|
|
623
|
+
),
|
|
624
|
+
].join("\n");
|
|
625
|
+
}
|
|
626
|
+
if (read.file === null) {
|
|
627
|
+
return `${head}: nothing was read.`;
|
|
628
|
+
}
|
|
629
|
+
return [
|
|
630
|
+
`--- file: ${read.file.path} (${read.file.totalLength} characters) ---`,
|
|
631
|
+
content.trimEnd(),
|
|
632
|
+
].join("\n");
|
|
633
|
+
}
|