versioncam 0.4.0 → 0.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/CHANGELOG.md +67 -0
- package/README.md +161 -9
- package/dist/cli/commands/cloud.d.ts +59 -0
- package/dist/cli/commands/cloud.js +891 -0
- package/dist/cli/commands/cloud.js.map +1 -0
- package/dist/cli/commands/publish.d.ts +17 -1
- package/dist/cli/commands/publish.js +34 -15
- package/dist/cli/commands/publish.js.map +1 -1
- package/dist/cli/main.js +2 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/usage.d.ts +4 -3
- package/dist/cli/usage.js +19 -2
- package/dist/cli/usage.js.map +1 -1
- package/dist/cloud/client.d.ts +54 -0
- package/dist/cloud/client.js +133 -0
- package/dist/cloud/client.js.map +1 -0
- package/dist/cloud/credentials.d.ts +35 -0
- package/dist/cloud/credentials.js +126 -0
- package/dist/cloud/credentials.js.map +1 -0
- package/dist/cloud/protocol.d.ts +220 -0
- package/dist/cloud/protocol.js +32 -0
- package/dist/cloud/protocol.js.map +1 -0
- package/dist/cloud/repository.d.ts +21 -0
- package/dist/cloud/repository.js +97 -0
- package/dist/cloud/repository.js.map +1 -0
- package/dist/cloud/wait.d.ts +43 -0
- package/dist/cloud/wait.js +110 -0
- package/dist/cloud/wait.js.map +1 -0
- package/dist/env.d.ts +6 -0
- package/dist/env.js +16 -3
- package/dist/env.js.map +1 -1
- package/package.json +2 -2
- package/plugin/README.md +25 -5
- package/plugin/skills/versioncam/SKILL.md +17 -5
- package/plugin/skills/versioncam/platform.md +201 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { homedir, userInfo } from "node:os";
|
|
3
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* `$XDG_CONFIG_HOME/versioncam/credentials.json`, else
|
|
6
|
+
* `~/.config/versioncam/credentials.json`. A relative `XDG_CONFIG_HOME` is
|
|
7
|
+
* ignored, as the XDG specification says it must be: it would put the token
|
|
8
|
+
* wherever the command happened to run.
|
|
9
|
+
*/
|
|
10
|
+
export function credentialsPath(env) {
|
|
11
|
+
const xdg = env.XDG_CONFIG_HOME;
|
|
12
|
+
const config = xdg && isAbsolute(xdg) ? xdg : join(env.HOME || accountHome(), ".config");
|
|
13
|
+
return join(config, "versioncam", "credentials.json");
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The account's home directory, for an environment with no HOME. Not
|
|
17
|
+
* `homedir()`, which reads this process's HOME, and that one a repository's
|
|
18
|
+
* `.env` could have set when the shell did not.
|
|
19
|
+
*/
|
|
20
|
+
function accountHome() {
|
|
21
|
+
try {
|
|
22
|
+
return userInfo().homedir;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// No entry for this user, as in some containers.
|
|
26
|
+
return homedir();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The key a sign-in is kept under: the API's origin. Staging and production
|
|
31
|
+
* sit side by side, and `VERSIONCAM_API` chooses between them without
|
|
32
|
+
* signing out of the other.
|
|
33
|
+
*/
|
|
34
|
+
export function apiOrigin(api) {
|
|
35
|
+
return new URL(api).origin;
|
|
36
|
+
}
|
|
37
|
+
export function readCredential(env, api) {
|
|
38
|
+
return readAll(credentialsPath(env))[apiOrigin(api)] ?? null;
|
|
39
|
+
}
|
|
40
|
+
/** Keep a sign-in, replacing this API's last one. Returns the file's path. */
|
|
41
|
+
export function saveCredential(env, api, credential) {
|
|
42
|
+
const path = credentialsPath(env);
|
|
43
|
+
const all = readAll(path);
|
|
44
|
+
all[apiOrigin(api)] = credential;
|
|
45
|
+
writeAll(path, all);
|
|
46
|
+
return path;
|
|
47
|
+
}
|
|
48
|
+
/** Forget this API's sign-in; the file goes when nothing is left in it. */
|
|
49
|
+
export function removeCredential(env, api) {
|
|
50
|
+
const path = credentialsPath(env);
|
|
51
|
+
const all = readAll(path);
|
|
52
|
+
const origin = apiOrigin(api);
|
|
53
|
+
if (!(origin in all))
|
|
54
|
+
return false;
|
|
55
|
+
delete all[origin];
|
|
56
|
+
writeAll(path, all);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Every sign-in in the file. One that cannot be read counts as none: it
|
|
61
|
+
* could not sign anything in, and signing in again writes a good one over
|
|
62
|
+
* it rather than refusing until someone finds the file by hand.
|
|
63
|
+
*/
|
|
64
|
+
function readAll(path) {
|
|
65
|
+
if (!existsSync(path))
|
|
66
|
+
return {};
|
|
67
|
+
let parsed;
|
|
68
|
+
try {
|
|
69
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return {};
|
|
73
|
+
}
|
|
74
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
75
|
+
return {};
|
|
76
|
+
}
|
|
77
|
+
const all = {};
|
|
78
|
+
for (const [origin, value] of Object.entries(parsed)) {
|
|
79
|
+
// Only what this file writes, an origin a key: anything else, such as
|
|
80
|
+
// a `__proto__` key, is not a sign-in and must not become a property.
|
|
81
|
+
if (!isOrigin(origin))
|
|
82
|
+
continue;
|
|
83
|
+
const entry = value;
|
|
84
|
+
if (typeof entry?.token === "string" &&
|
|
85
|
+
typeof entry.login === "string" &&
|
|
86
|
+
typeof entry.expiresAt === "string") {
|
|
87
|
+
all[origin] = {
|
|
88
|
+
token: entry.token,
|
|
89
|
+
login: entry.login,
|
|
90
|
+
expiresAt: entry.expiresAt,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return all;
|
|
95
|
+
}
|
|
96
|
+
function isOrigin(key) {
|
|
97
|
+
try {
|
|
98
|
+
return new URL(key).origin === key;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Write the file whole, through a file beside it that is created 0600 and
|
|
106
|
+
* renamed over it: no moment exists at which the token is in a file anyone
|
|
107
|
+
* else may read, or in half a file. The modes are set again after creating,
|
|
108
|
+
* because a directory that was already there keeps the mode it had, and a
|
|
109
|
+
* file's creation mode is only what the umask lets through.
|
|
110
|
+
*/
|
|
111
|
+
function writeAll(path, all) {
|
|
112
|
+
const dir = dirname(path);
|
|
113
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
114
|
+
chmodSync(dir, 0o700);
|
|
115
|
+
if (Object.keys(all).length === 0) {
|
|
116
|
+
rmSync(path, { force: true });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
120
|
+
writeFileSync(temporary, `${JSON.stringify(all, null, 2)}\n`, {
|
|
121
|
+
mode: 0o600,
|
|
122
|
+
});
|
|
123
|
+
chmodSync(temporary, 0o600);
|
|
124
|
+
renameSync(temporary, path);
|
|
125
|
+
}
|
|
126
|
+
//# sourceMappingURL=credentials.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"credentials.js","sourceRoot":"","sources":["../../src/cloud/credentials.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The management API's wire, as the command line reads it: what `versioncam
|
|
3
|
+
* cloud` sends to version.cam's api host, and what comes back.
|
|
4
|
+
*
|
|
5
|
+
* Written from the contract the service and the command line were built
|
|
6
|
+
* against at the same time, and kept free of Node like
|
|
7
|
+
* `../publish/protocol.ts`, so that the service can one day import it as it
|
|
8
|
+
* imports that one, and a field added on one side becomes a type error on the
|
|
9
|
+
* other.
|
|
10
|
+
*
|
|
11
|
+
* Every route is JSON in and out, on the api host, and reads no cookie. A
|
|
12
|
+
* route that needs a person takes `Authorization: Bearer vcu_…`, the token
|
|
13
|
+
* `versioncam cloud login` was given.
|
|
14
|
+
*/
|
|
15
|
+
/** Who can see a project's clips: the dashboard's three words. */
|
|
16
|
+
export type Visibility = "public" | "unlisted" | "private";
|
|
17
|
+
/**
|
|
18
|
+
* Every refusal: a code for the command line to act on, and a sentence for
|
|
19
|
+
* the person. A few carry one more field: `plan` the upgrade page's address,
|
|
20
|
+
* `rate_limited` how many seconds to wait.
|
|
21
|
+
*/
|
|
22
|
+
export type CloudErrorBody = {
|
|
23
|
+
error: string;
|
|
24
|
+
message: string;
|
|
25
|
+
upgrade?: string;
|
|
26
|
+
retry_after?: number;
|
|
27
|
+
};
|
|
28
|
+
/** `POST /v1/cli/device`: the start of a sign-in, in RFC 8628's shape. */
|
|
29
|
+
export type DeviceAuthorization = {
|
|
30
|
+
device_code: string;
|
|
31
|
+
user_code: string;
|
|
32
|
+
verification_uri: string;
|
|
33
|
+
verification_uri_complete: string;
|
|
34
|
+
expires_in: number;
|
|
35
|
+
interval: number;
|
|
36
|
+
};
|
|
37
|
+
/** `POST /v1/cli/token`, once the person approved: the token, shown once. */
|
|
38
|
+
export type DeviceToken = {
|
|
39
|
+
token: string;
|
|
40
|
+
login: string;
|
|
41
|
+
expires_at: string;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* The four answers a token request gives before it gives a token, each a 400
|
|
45
|
+
* with `error` set to it. The first two say to keep asking; the last two end
|
|
46
|
+
* the sign-in.
|
|
47
|
+
*/
|
|
48
|
+
export type DevicePending = "authorization_pending" | "slow_down" | "expired_token" | "access_denied";
|
|
49
|
+
/** `GET /v1/me`: who the token is, and every project they may see. */
|
|
50
|
+
export type Me = {
|
|
51
|
+
login: string;
|
|
52
|
+
accounts: {
|
|
53
|
+
login: string;
|
|
54
|
+
/** Null when the dashboard would not show this person the account's plan. */
|
|
55
|
+
plan: {
|
|
56
|
+
name: string;
|
|
57
|
+
title: string;
|
|
58
|
+
} | null;
|
|
59
|
+
projects: {
|
|
60
|
+
project: string;
|
|
61
|
+
visibility: Visibility;
|
|
62
|
+
clips: number;
|
|
63
|
+
broken: number;
|
|
64
|
+
}[];
|
|
65
|
+
}[];
|
|
66
|
+
};
|
|
67
|
+
/** What plays at a clip's living address: the newest version that passed. */
|
|
68
|
+
export type Plays = {
|
|
69
|
+
version: string;
|
|
70
|
+
commit: string;
|
|
71
|
+
recordedAt: string;
|
|
72
|
+
};
|
|
73
|
+
/** Who said "I know, keep the old one", and when. */
|
|
74
|
+
export type Acknowledgment = {
|
|
75
|
+
by: string;
|
|
76
|
+
at: string;
|
|
77
|
+
};
|
|
78
|
+
/** One clip in a project's status. */
|
|
79
|
+
export type ClipStatus = {
|
|
80
|
+
clip: string;
|
|
81
|
+
title: string;
|
|
82
|
+
status: "current" | "broken";
|
|
83
|
+
brokenSince: string | null;
|
|
84
|
+
acknowledged: Acknowledgment | null;
|
|
85
|
+
plays: Plays | null;
|
|
86
|
+
checkedAt: string | null;
|
|
87
|
+
/** The living address; signed for an hour on a private project. */
|
|
88
|
+
url: string;
|
|
89
|
+
};
|
|
90
|
+
/** `GET /v1/projects/:owner/:repo`. */
|
|
91
|
+
export type ProjectStatus = {
|
|
92
|
+
project: string;
|
|
93
|
+
/** The project's page on the dashboard. */
|
|
94
|
+
manage: string;
|
|
95
|
+
visibility: Visibility;
|
|
96
|
+
/** "Tell viewers when a clip is broken". */
|
|
97
|
+
showBroken: boolean;
|
|
98
|
+
permission: "admin" | "read";
|
|
99
|
+
plan: {
|
|
100
|
+
name: string;
|
|
101
|
+
title: string;
|
|
102
|
+
alerts: boolean;
|
|
103
|
+
};
|
|
104
|
+
/** Who is told when a clip breaks: for someone who may change the project, else null. */
|
|
105
|
+
alerts: {
|
|
106
|
+
email: string | null;
|
|
107
|
+
emailConfirmed: boolean;
|
|
108
|
+
slack: boolean;
|
|
109
|
+
issues: boolean;
|
|
110
|
+
} | null;
|
|
111
|
+
/** This month's. */
|
|
112
|
+
usage: {
|
|
113
|
+
publishes: number;
|
|
114
|
+
renderSeconds: number;
|
|
115
|
+
storageBytes: number;
|
|
116
|
+
};
|
|
117
|
+
clips: ClipStatus[];
|
|
118
|
+
};
|
|
119
|
+
/** Where a clip's check stopped, the last time it failed on the default branch. */
|
|
120
|
+
export type ClipFailure = {
|
|
121
|
+
since: string;
|
|
122
|
+
commit: string;
|
|
123
|
+
step: {
|
|
124
|
+
index: number;
|
|
125
|
+
call: string;
|
|
126
|
+
} | null;
|
|
127
|
+
message: string;
|
|
128
|
+
/** The page when the run gave up; signed for an hour on a private project. */
|
|
129
|
+
picture: string | null;
|
|
130
|
+
};
|
|
131
|
+
/** `GET /v1/projects/:owner/:repo/clips/:clip`. */
|
|
132
|
+
export type ClipDetail = {
|
|
133
|
+
clip: string;
|
|
134
|
+
title: string;
|
|
135
|
+
status: "current" | "broken";
|
|
136
|
+
/** The living address; signed for an hour on a private project. */
|
|
137
|
+
url: string;
|
|
138
|
+
/**
|
|
139
|
+
* What the clip's page offers to paste, or null while nothing of the clip
|
|
140
|
+
* has passed on the default branch, when there is nothing to embed.
|
|
141
|
+
* `video` is null when the version has no MP4.
|
|
142
|
+
*/
|
|
143
|
+
embed: {
|
|
144
|
+
script: string;
|
|
145
|
+
iframe: string;
|
|
146
|
+
video: string | null;
|
|
147
|
+
markdown: string;
|
|
148
|
+
} | null;
|
|
149
|
+
/** Signed for an hour on a private project, the GIF's too; null as `embed` is. */
|
|
150
|
+
files: {
|
|
151
|
+
mp4: string | null;
|
|
152
|
+
webm: string | null;
|
|
153
|
+
poster: string | null;
|
|
154
|
+
sheet: string | null;
|
|
155
|
+
gif: string | null;
|
|
156
|
+
} | null;
|
|
157
|
+
failure: ClipFailure | null;
|
|
158
|
+
};
|
|
159
|
+
/** `GET /v1/accounts/:login/plan`. */
|
|
160
|
+
export type AccountPlan = {
|
|
161
|
+
account: string;
|
|
162
|
+
plan: {
|
|
163
|
+
name: string;
|
|
164
|
+
title: string;
|
|
165
|
+
source: "free" | "beta" | "subscription" | "override";
|
|
166
|
+
until: string | null;
|
|
167
|
+
quantity: number | null;
|
|
168
|
+
};
|
|
169
|
+
upgrade: string;
|
|
170
|
+
/** The billing page, for someone who may bill the account; else null. */
|
|
171
|
+
billing: string | null;
|
|
172
|
+
};
|
|
173
|
+
/** `POST …/clips/:clip/ack`. */
|
|
174
|
+
export type Acknowledged = {
|
|
175
|
+
acknowledged: Acknowledgment;
|
|
176
|
+
};
|
|
177
|
+
/**
|
|
178
|
+
* A change to one setting, exactly one of these fields. Each is what the
|
|
179
|
+
* dashboard's settings form takes for the same setting; `retentionDays: null`
|
|
180
|
+
* keeps every version the plan allows, as the form's empty field does.
|
|
181
|
+
*/
|
|
182
|
+
export type ChangeRequest = {
|
|
183
|
+
visibility: Visibility;
|
|
184
|
+
} | {
|
|
185
|
+
retentionDays: number | null;
|
|
186
|
+
} | {
|
|
187
|
+
showBroken: boolean;
|
|
188
|
+
} | {
|
|
189
|
+
alertEmail: string | null;
|
|
190
|
+
} | {
|
|
191
|
+
slackWebhook: string | null;
|
|
192
|
+
} | {
|
|
193
|
+
staleIssues: boolean;
|
|
194
|
+
};
|
|
195
|
+
/** `POST …/changes`: the change waits for the person to approve it. */
|
|
196
|
+
export type ChangePending = {
|
|
197
|
+
change: string;
|
|
198
|
+
user_code: string;
|
|
199
|
+
verification_uri: string;
|
|
200
|
+
verification_uri_complete: string;
|
|
201
|
+
expires_in: number;
|
|
202
|
+
/** The change in a sentence, as the approval page shows it. */
|
|
203
|
+
describe: string;
|
|
204
|
+
};
|
|
205
|
+
/** `GET …/changes/:change`. */
|
|
206
|
+
export type ChangeState = {
|
|
207
|
+
state: "pending" | "applied" | "denied" | "expired";
|
|
208
|
+
};
|
|
209
|
+
/** Paths, relative to the API's origin. Names are escaped: a clip id may hold a dot. */
|
|
210
|
+
export declare const CLOUD_PATHS: {
|
|
211
|
+
readonly device: "/v1/cli/device";
|
|
212
|
+
readonly token: "/v1/cli/token";
|
|
213
|
+
readonly me: "/v1/me";
|
|
214
|
+
readonly project: (project: string) => string;
|
|
215
|
+
readonly clip: (project: string, clip: string) => string;
|
|
216
|
+
readonly ack: (project: string, clip: string) => string;
|
|
217
|
+
readonly changes: (project: string) => string;
|
|
218
|
+
readonly change: (project: string, change: string) => string;
|
|
219
|
+
readonly plan: (account: string) => string;
|
|
220
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The management API's wire, as the command line reads it: what `versioncam
|
|
3
|
+
* cloud` sends to version.cam's api host, and what comes back.
|
|
4
|
+
*
|
|
5
|
+
* Written from the contract the service and the command line were built
|
|
6
|
+
* against at the same time, and kept free of Node like
|
|
7
|
+
* `../publish/protocol.ts`, so that the service can one day import it as it
|
|
8
|
+
* imports that one, and a field added on one side becomes a type error on the
|
|
9
|
+
* other.
|
|
10
|
+
*
|
|
11
|
+
* Every route is JSON in and out, on the api host, and reads no cookie. A
|
|
12
|
+
* route that needs a person takes `Authorization: Bearer vcu_…`, the token
|
|
13
|
+
* `versioncam cloud login` was given.
|
|
14
|
+
*/
|
|
15
|
+
/** Paths, relative to the API's origin. Names are escaped: a clip id may hold a dot. */
|
|
16
|
+
export const CLOUD_PATHS = {
|
|
17
|
+
device: "/v1/cli/device",
|
|
18
|
+
token: "/v1/cli/token",
|
|
19
|
+
me: "/v1/me",
|
|
20
|
+
project: (project) => `/v1/projects/${projectPath(project)}`,
|
|
21
|
+
clip: (project, clip) => `/v1/projects/${projectPath(project)}/clips/${encodeURIComponent(clip)}`,
|
|
22
|
+
ack: (project, clip) => `/v1/projects/${projectPath(project)}/clips/${encodeURIComponent(clip)}/ack`,
|
|
23
|
+
changes: (project) => `/v1/projects/${projectPath(project)}/changes`,
|
|
24
|
+
change: (project, change) => `/v1/projects/${projectPath(project)}/changes/${encodeURIComponent(change)}`,
|
|
25
|
+
plan: (account) => `/v1/accounts/${encodeURIComponent(account)}/plan`,
|
|
26
|
+
};
|
|
27
|
+
/** `owner/repo` as two path segments, each escaped on its own. */
|
|
28
|
+
function projectPath(project) {
|
|
29
|
+
const [owner, repo] = project.split("/");
|
|
30
|
+
return `${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=protocol.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"protocol.js","sourceRoot":"","sources":["../../src/cloud/protocol.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAyLH,wFAAwF;AACxF,MAAM,CAAC,MAAM,WAAW,GAAG;IACzB,MAAM,EAAE,gBAAgB;IACxB,KAAK,EAAE,eAAe;IACtB,EAAE,EAAE,QAAQ;IACZ,OAAO,EAAE,CAAC,OAAe,EAAE,EAAE,CAAC,gBAAgB,WAAW,CAAC,OAAO,CAAC,EAAE;IACpE,IAAI,EAAE,CAAC,OAAe,EAAE,IAAY,EAAE,EAAE,CACtC,gBAAgB,WAAW,CAAC,OAAO,CAAC,UAAU,kBAAkB,CAAC,IAAI,CAAC,EAAE;IAC1E,GAAG,EAAE,CAAC,OAAe,EAAE,IAAY,EAAE,EAAE,CACrC,gBAAgB,WAAW,CAAC,OAAO,CAAC,UAAU,kBAAkB,CAAC,IAAI,CAAC,MAAM;IAC9E,OAAO,EAAE,CAAC,OAAe,EAAE,EAAE,CAAC,gBAAgB,WAAW,CAAC,OAAO,CAAC,UAAU;IAC5E,MAAM,EAAE,CAAC,OAAe,EAAE,MAAc,EAAE,EAAE,CAC1C,gBAAgB,WAAW,CAAC,OAAO,CAAC,YAAY,kBAAkB,CAAC,MAAM,CAAC,EAAE;IAC9E,IAAI,EAAE,CAAC,OAAe,EAAE,EAAE,CAAC,gBAAgB,kBAAkB,CAAC,OAAO,CAAC,OAAO;CACrE,CAAC;AAEX,kEAAkE;AAClE,SAAS,WAAW,CAAC,OAAe;IAClC,MAAM,CAAC,KAAK,EAAE,IAAI,CAAC,GAAG,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACzC,OAAO,GAAG,kBAAkB,CAAC,KAAK,CAAC,IAAI,kBAAkB,CAAC,IAAI,CAAC,EAAE,CAAC;AACpE,CAAC"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** `owner/repo` if `text` is one, else null. */
|
|
2
|
+
export declare function projectName(text: string): string | null;
|
|
3
|
+
/** An account's name as GitHub allows it, else null. */
|
|
4
|
+
export declare function accountName(text: string): string | null;
|
|
5
|
+
/**
|
|
6
|
+
* The `owner/repo` a GitHub remote points at, from any of the forms GitHub
|
|
7
|
+
* hands out or git accepts for it: `https://github.com/owner/repo.git`, with
|
|
8
|
+
* a user or a token before the host or without the `.git`;
|
|
9
|
+
* `git@github.com:owner/repo.git`; `ssh://git@github.com/owner/repo.git`,
|
|
10
|
+
* with a port or on `ssh.github.com`. Any other host is null: version.cam
|
|
11
|
+
* knows projects on github.com only.
|
|
12
|
+
*/
|
|
13
|
+
export declare function githubRepository(remote: string): string | null;
|
|
14
|
+
/**
|
|
15
|
+
* The repository of `git remote get-url origin` in `cwd`, or null: not a
|
|
16
|
+
* clone, no `origin`, no git, or an origin that is not on GitHub. `get-url`
|
|
17
|
+
* rather than the config's raw value, because it applies a `url.insteadOf`
|
|
18
|
+
* rewrite the way a fetch would. `env` is git's environment, the command's
|
|
19
|
+
* own unless a test isolates git from the machine's configuration.
|
|
20
|
+
*/
|
|
21
|
+
export declare function originRepository(cwd: string, env?: NodeJS.ProcessEnv): string | null;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Which project a command is about, when nobody says: the GitHub repository
|
|
4
|
+
* this directory is a clone of. A project on version.cam is a repository's
|
|
5
|
+
* `owner/repo`, so its `origin` remote names it, from wherever in the clone
|
|
6
|
+
* the command runs.
|
|
7
|
+
*/
|
|
8
|
+
/** GitHub's own rule for an account: letters, digits and hyphens, at most 39. */
|
|
9
|
+
const OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
|
|
10
|
+
/** And for a repository: letters, digits, dots, hyphens and underscores, never `.` or `..`. */
|
|
11
|
+
const REPO = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,100}$/;
|
|
12
|
+
/**
|
|
13
|
+
* GitHub's hosts for git: the site itself, and `ssh.github.com`, which
|
|
14
|
+
* GitHub documents for SSH over port 443 where port 22 is closed.
|
|
15
|
+
*/
|
|
16
|
+
const GITHUB_HOSTS = new Set([
|
|
17
|
+
"github.com",
|
|
18
|
+
"www.github.com",
|
|
19
|
+
"ssh.github.com",
|
|
20
|
+
]);
|
|
21
|
+
/** `owner/repo` if `text` is one, else null. */
|
|
22
|
+
export function projectName(text) {
|
|
23
|
+
const parts = text.split("/");
|
|
24
|
+
if (parts.length !== 2)
|
|
25
|
+
return null;
|
|
26
|
+
const [owner, repo] = parts;
|
|
27
|
+
return OWNER.test(owner) && REPO.test(repo) ? `${owner}/${repo}` : null;
|
|
28
|
+
}
|
|
29
|
+
/** An account's name as GitHub allows it, else null. */
|
|
30
|
+
export function accountName(text) {
|
|
31
|
+
return OWNER.test(text) ? text : null;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The `owner/repo` a GitHub remote points at, from any of the forms GitHub
|
|
35
|
+
* hands out or git accepts for it: `https://github.com/owner/repo.git`, with
|
|
36
|
+
* a user or a token before the host or without the `.git`;
|
|
37
|
+
* `git@github.com:owner/repo.git`; `ssh://git@github.com/owner/repo.git`,
|
|
38
|
+
* with a port or on `ssh.github.com`. Any other host is null: version.cam
|
|
39
|
+
* knows projects on github.com only.
|
|
40
|
+
*/
|
|
41
|
+
export function githubRepository(remote) {
|
|
42
|
+
const url = remote.trim();
|
|
43
|
+
let host;
|
|
44
|
+
let path;
|
|
45
|
+
// The short SSH form, `user@host:path`, has no scheme, and git reads a
|
|
46
|
+
// colon before any slash as the end of the host.
|
|
47
|
+
const scp = /^(?:[^@/\s]+@)?([^:/\s]+):(?!\/)(\S+)$/.exec(url);
|
|
48
|
+
if (scp && !/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(url)) {
|
|
49
|
+
host = scp[1];
|
|
50
|
+
path = scp[2];
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
let parsed;
|
|
54
|
+
try {
|
|
55
|
+
parsed = new URL(url);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
if (!["https:", "http:", "ssh:", "git:", "git+ssh:"].includes(parsed.protocol)) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
host = parsed.hostname;
|
|
64
|
+
// Not decoded: no name GitHub allows needs escaping, so an escape in
|
|
65
|
+
// the path is a name it would not allow, and the check below says no.
|
|
66
|
+
path = parsed.pathname;
|
|
67
|
+
}
|
|
68
|
+
if (!GITHUB_HOSTS.has(host.toLowerCase()))
|
|
69
|
+
return null;
|
|
70
|
+
return projectName(path
|
|
71
|
+
.replace(/^\/+/, "")
|
|
72
|
+
.replace(/\/+$/, "")
|
|
73
|
+
.replace(/\.git$/, ""));
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The repository of `git remote get-url origin` in `cwd`, or null: not a
|
|
77
|
+
* clone, no `origin`, no git, or an origin that is not on GitHub. `get-url`
|
|
78
|
+
* rather than the config's raw value, because it applies a `url.insteadOf`
|
|
79
|
+
* rewrite the way a fetch would. `env` is git's environment, the command's
|
|
80
|
+
* own unless a test isolates git from the machine's configuration.
|
|
81
|
+
*/
|
|
82
|
+
export function originRepository(cwd, env = process.env) {
|
|
83
|
+
let remote;
|
|
84
|
+
try {
|
|
85
|
+
remote = execFileSync("git", ["remote", "get-url", "origin"], {
|
|
86
|
+
cwd,
|
|
87
|
+
env,
|
|
88
|
+
encoding: "utf8",
|
|
89
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return githubRepository(remote);
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=repository.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"repository.js","sourceRoot":"","sources":["../../src/cloud/repository.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type CloudClient } from "./client.js";
|
|
2
|
+
import type { ChangePending, ChangeState, DeviceAuthorization, DeviceToken } from "./protocol.js";
|
|
3
|
+
/**
|
|
4
|
+
* Waiting for a person: a sign-in or a change, approved in a browser while
|
|
5
|
+
* the command asks, now and then, whether it has been.
|
|
6
|
+
*
|
|
7
|
+
* Time comes from a clock the caller hands over, so a test can wait ten
|
|
8
|
+
* minutes in no time at all and see every interval the command chose.
|
|
9
|
+
*/
|
|
10
|
+
export type Clock = {
|
|
11
|
+
/** Milliseconds, as `Date.now()`. */
|
|
12
|
+
now(): number;
|
|
13
|
+
sleep(ms: number): Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
export declare const realClock: Clock;
|
|
16
|
+
/** The longest a command waits: as long as a code lives, ten minutes. */
|
|
17
|
+
export declare const WAIT_LIMIT_MS: number;
|
|
18
|
+
/**
|
|
19
|
+
* How long to wait for a code that lives `expiresIn` seconds: as long as it
|
|
20
|
+
* does, never past ten minutes, and ten minutes when the answer did not say.
|
|
21
|
+
*/
|
|
22
|
+
export declare function waitLimit(expiresIn: number | undefined): number;
|
|
23
|
+
/** How often a change is asked after, which the service's answer does not say. */
|
|
24
|
+
export declare const CHANGE_INTERVAL_MS = 5000;
|
|
25
|
+
export type SignIn = {
|
|
26
|
+
kind: "token";
|
|
27
|
+
token: DeviceToken;
|
|
28
|
+
} | {
|
|
29
|
+
kind: "denied";
|
|
30
|
+
} | {
|
|
31
|
+
kind: "expired";
|
|
32
|
+
} | {
|
|
33
|
+
kind: "timeout";
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Ask for the token at the service's interval, as RFC 8628 has a device do,
|
|
37
|
+
* until it comes, the person denies it, or the code expires. Never longer
|
|
38
|
+
* than the code lives, nor than ten minutes.
|
|
39
|
+
*/
|
|
40
|
+
export declare function waitForToken(client: CloudClient, device: DeviceAuthorization, clock: Clock): Promise<SignIn>;
|
|
41
|
+
export type ChangeOutcome = Exclude<ChangeState["state"], "pending"> | "timeout";
|
|
42
|
+
/** Ask after a change every five seconds until it is applied, denied or expired. */
|
|
43
|
+
export declare function waitForChange(client: CloudClient, project: string, pending: ChangePending, clock: Clock): Promise<ChangeOutcome>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { CloudError, transient } from "./client.js";
|
|
2
|
+
export const realClock = {
|
|
3
|
+
now: () => Date.now(),
|
|
4
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
5
|
+
};
|
|
6
|
+
/** The longest a command waits: as long as a code lives, ten minutes. */
|
|
7
|
+
export const WAIT_LIMIT_MS = 10 * 60 * 1000;
|
|
8
|
+
/**
|
|
9
|
+
* How long to wait for a code that lives `expiresIn` seconds: as long as it
|
|
10
|
+
* does, never past ten minutes, and ten minutes when the answer did not say.
|
|
11
|
+
*/
|
|
12
|
+
export function waitLimit(expiresIn) {
|
|
13
|
+
return Math.min((expiresIn || 600) * 1000, WAIT_LIMIT_MS);
|
|
14
|
+
}
|
|
15
|
+
/** How often a change is asked after, which the service's answer does not say. */
|
|
16
|
+
export const CHANGE_INTERVAL_MS = 5000;
|
|
17
|
+
/** RFC 8628, 3.5: each `slow_down` adds five seconds, to this wait and every later one. */
|
|
18
|
+
const SLOW_DOWN_MS = 5000;
|
|
19
|
+
/**
|
|
20
|
+
* Ask every `intervalMs` until an answer ends it, or until the next question
|
|
21
|
+
* would come after `limitMs`; null then. The first question waits an
|
|
22
|
+
* interval too: nobody has approved anything in the moment since the code
|
|
23
|
+
* was printed.
|
|
24
|
+
*
|
|
25
|
+
* A question that got no answer, or was turned away for coming too often, or
|
|
26
|
+
* met a failing service, is asked again at the next turn rather than ending
|
|
27
|
+
* the wait: the person may be approving it at that moment, and a sign-in
|
|
28
|
+
* given up on is a code they approve for nothing.
|
|
29
|
+
*
|
|
30
|
+
* But a wait whose last question failed ends with that failure, thrown,
|
|
31
|
+
* not with null. Null says nobody approved in time, which only an answer
|
|
32
|
+
* can say; a service that kept failing (its `internal` is a bug) may have
|
|
33
|
+
* been approved while it failed, and the person should hear what happened.
|
|
34
|
+
*/
|
|
35
|
+
async function poll(ask, intervalMs, limitMs, clock) {
|
|
36
|
+
const deadline = clock.now() + limitMs;
|
|
37
|
+
let interval = intervalMs;
|
|
38
|
+
let wait = interval;
|
|
39
|
+
let failed = null;
|
|
40
|
+
while (clock.now() + wait <= deadline) {
|
|
41
|
+
await clock.sleep(wait);
|
|
42
|
+
wait = interval;
|
|
43
|
+
let step;
|
|
44
|
+
try {
|
|
45
|
+
step = await ask();
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (!transient(error))
|
|
49
|
+
throw error;
|
|
50
|
+
failed = error;
|
|
51
|
+
wait = Math.max(interval, (error.retryAfter ?? 0) * 1000);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
failed = null;
|
|
55
|
+
if (step === "again")
|
|
56
|
+
continue;
|
|
57
|
+
if (step === "slower") {
|
|
58
|
+
interval += SLOW_DOWN_MS;
|
|
59
|
+
wait = interval;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
return step.done;
|
|
63
|
+
}
|
|
64
|
+
if (failed)
|
|
65
|
+
throw failed;
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Ask for the token at the service's interval, as RFC 8628 has a device do,
|
|
70
|
+
* until it comes, the person denies it, or the code expires. Never longer
|
|
71
|
+
* than the code lives, nor than ten minutes.
|
|
72
|
+
*/
|
|
73
|
+
export async function waitForToken(client, device, clock) {
|
|
74
|
+
// RFC 8628 has a device wait five seconds when the answer gave no interval.
|
|
75
|
+
const interval = Math.max(1, device.interval || 5) * 1000;
|
|
76
|
+
const outcome = await poll(async () => {
|
|
77
|
+
try {
|
|
78
|
+
return {
|
|
79
|
+
done: {
|
|
80
|
+
kind: "token",
|
|
81
|
+
token: await client.token(device.device_code),
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (!(error instanceof CloudError))
|
|
87
|
+
throw error;
|
|
88
|
+
if (error.code === "authorization_pending")
|
|
89
|
+
return "again";
|
|
90
|
+
if (error.code === "slow_down")
|
|
91
|
+
return "slower";
|
|
92
|
+
if (error.code === "access_denied")
|
|
93
|
+
return { done: { kind: "denied" } };
|
|
94
|
+
if (error.code === "expired_token") {
|
|
95
|
+
return { done: { kind: "expired" } };
|
|
96
|
+
}
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}, interval, waitLimit(device.expires_in), clock);
|
|
100
|
+
return outcome ?? { kind: "timeout" };
|
|
101
|
+
}
|
|
102
|
+
/** Ask after a change every five seconds until it is applied, denied or expired. */
|
|
103
|
+
export async function waitForChange(client, project, pending, clock) {
|
|
104
|
+
const outcome = await poll(async () => {
|
|
105
|
+
const { state } = await client.changeState(project, pending.change);
|
|
106
|
+
return state === "pending" ? "again" : { done: state };
|
|
107
|
+
}, CHANGE_INTERVAL_MS, waitLimit(pending.expires_in), clock);
|
|
108
|
+
return outcome ?? "timeout";
|
|
109
|
+
}
|
|
110
|
+
//# sourceMappingURL=wait.js.map
|