ignotum 0.0.8 → 0.0.9
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 +6 -2
- package/dist/cli/bin.mjs +294 -64
- package/dist/cli/bin.mjs.map +1 -1
- package/dist/runtime/{api-BTeMI5tx.js → api-DzcR7spt.js} +22 -23
- package/dist/runtime/api-DzcR7spt.js.map +1 -0
- package/dist/runtime/{api-D9lV-5av.d.ts → api-jCl8Hzry.d.ts} +3 -3
- package/dist/runtime/client.d.ts +5 -5
- package/dist/runtime/client.js +57 -55
- package/dist/runtime/client.js.map +1 -1
- package/dist/runtime/{descriptor-C5VA9qRl-C338iC6l.js → descriptor-C5VA9qRl-BKbdenuU.js} +2 -2
- package/dist/runtime/{descriptor-C5VA9qRl-C338iC6l.js.map → descriptor-C5VA9qRl-BKbdenuU.js.map} +1 -1
- package/dist/runtime/{file-BXf63ulU.js → file-C1abuMgd.js} +8 -1
- package/dist/runtime/file-C1abuMgd.js.map +1 -0
- package/dist/runtime/{id-Btwac71X-DGj0DQuu.d.ts → id-Cs82tq9Q-Caqfx54f.d.ts} +2 -2
- package/dist/runtime/{index-CF04_Dps.d.ts → index-D6VLTbDB.d.ts} +4 -4
- package/dist/runtime/internal/api.d.ts +1 -1
- package/dist/runtime/internal/api.js +1 -1
- package/dist/runtime/internal/host.d.ts +4 -4
- package/dist/runtime/internal/host.js +3 -3
- package/dist/runtime/internal/server.d.ts +1 -1
- package/dist/runtime/internal/server.js +1 -1
- package/dist/runtime/internal/types.d.ts +1 -1
- package/dist/runtime/internal/types.js +1 -1
- package/dist/runtime/{pagination-DrOowBve-Bnipd34u.d.ts → pagination-Bt3l7QaC-D_zI5zsu.d.ts} +3 -3
- package/dist/runtime/pagination-DcIkTOFs.d.ts +1 -0
- package/dist/runtime/{schema-DJfwfq87.js → schema-B6PK_ZwV.js} +3 -3
- package/dist/runtime/{schema-DJfwfq87.js.map → schema-B6PK_ZwV.js.map} +1 -1
- package/dist/runtime/server.d.ts +2 -2
- package/dist/runtime/server.js +2 -2
- package/dist/runtime/{sync-mIXn9eKv.d.ts → sync-bMQq9tXx.d.ts} +2 -2
- package/package.json +4 -2
- package/src/cli/auth-client.ts +286 -0
- package/src/cli/command.ts +65 -2
- package/src/cli/control-client.ts +19 -6
- package/src/cli/new-app.ts +2 -1
- package/src/client/errors.ts +7 -10
- package/src/client/files.ts +36 -51
- package/src/client/sync.ts +35 -23
- package/src/dev-runtime/files.ts +30 -31
- package/src/dev-runtime/sync.ts +19 -25
- package/dist/runtime/api-BTeMI5tx.js.map +0 -1
- package/dist/runtime/file-BXf63ulU.js.map +0 -1
- package/dist/runtime/pagination-BNFhAjns.d.ts +0 -1
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
|
|
4
|
+
import * as Versioned from "@ignotum/contracts/versioned";
|
|
5
|
+
import { createAuthClient } from "better-auth/client";
|
|
6
|
+
import { deviceAuthorizationClient, organizationClient } from "better-auth/client/plugins";
|
|
7
|
+
import {
|
|
8
|
+
Config,
|
|
9
|
+
Context,
|
|
10
|
+
Effect,
|
|
11
|
+
FileSystem,
|
|
12
|
+
Layer,
|
|
13
|
+
Option,
|
|
14
|
+
Path,
|
|
15
|
+
Redacted,
|
|
16
|
+
Schema,
|
|
17
|
+
Terminal,
|
|
18
|
+
} from "effect";
|
|
19
|
+
|
|
20
|
+
const CredentialV1 = Schema.Struct({
|
|
21
|
+
formatVersion: Schema.Literal(1),
|
|
22
|
+
authUrl: Schema.URLFromString,
|
|
23
|
+
token: Schema.String,
|
|
24
|
+
});
|
|
25
|
+
const CredentialThroughV1 = Versioned.initial(CredentialV1);
|
|
26
|
+
const Credential = CredentialThroughV1;
|
|
27
|
+
type Credential = typeof Credential.Type;
|
|
28
|
+
|
|
29
|
+
const AuthUrl = Schema.URL.check(
|
|
30
|
+
Schema.makeFilter(
|
|
31
|
+
(url) =>
|
|
32
|
+
url.protocol === "https:" ||
|
|
33
|
+
(url.protocol === "http:" && ["127.0.0.1", "::1", "localhost"].includes(url.hostname)),
|
|
34
|
+
{ message: "The auth URL must use HTTPS unless it targets localhost." },
|
|
35
|
+
),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
export class AuthCommandError extends Schema.TaggedError<AuthCommandError>()("AuthCommandError", {
|
|
39
|
+
cause: Schema.optional(Schema.Defect()),
|
|
40
|
+
message: Schema.String,
|
|
41
|
+
}) {}
|
|
42
|
+
|
|
43
|
+
const commandError = (message: string, cause?: unknown) =>
|
|
44
|
+
AuthCommandError.make(cause === undefined ? { message } : { cause, message });
|
|
45
|
+
|
|
46
|
+
interface StoredCredential {
|
|
47
|
+
readonly authUrl: URL;
|
|
48
|
+
readonly token: Redacted.Redacted<string>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface AuthCredentialStoreService {
|
|
52
|
+
readonly load: Effect.Effect<StoredCredential | undefined, AuthCommandError>;
|
|
53
|
+
readonly remove: Effect.Effect<boolean, AuthCommandError>;
|
|
54
|
+
readonly save: (credential: StoredCredential) => Effect.Effect<void, AuthCommandError>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class AuthCredentialStore extends Context.Service<
|
|
58
|
+
AuthCredentialStore,
|
|
59
|
+
AuthCredentialStoreService
|
|
60
|
+
>()("ignotum/cli/auth-client/AuthCredentialStore") {
|
|
61
|
+
static readonly layer = Layer.effect(
|
|
62
|
+
AuthCredentialStore,
|
|
63
|
+
Effect.gen(function* () {
|
|
64
|
+
const fileSystem = yield* FileSystem.FileSystem;
|
|
65
|
+
const path = yield* Path.Path;
|
|
66
|
+
const configured = yield* Config.option(Config.string("IGNOTUM_CONFIG_DIR"));
|
|
67
|
+
const xdg = yield* Config.option(Config.string("XDG_CONFIG_HOME"));
|
|
68
|
+
const appData = yield* Config.option(Config.string("APPDATA"));
|
|
69
|
+
const configurationDirectory = Option.getOrElse(configured, () =>
|
|
70
|
+
Option.getOrElse(xdg, () =>
|
|
71
|
+
process.platform === "win32" && Option.isSome(appData)
|
|
72
|
+
? appData.value
|
|
73
|
+
: path.join(homedir(), ".config"),
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
const directory = path.join(configurationDirectory, "ignotum");
|
|
77
|
+
const credentialPath = path.join(directory, "auth.json");
|
|
78
|
+
|
|
79
|
+
const load = Effect.fn("AuthCredentialStore.load")(function* () {
|
|
80
|
+
if (
|
|
81
|
+
!(yield* fileSystem
|
|
82
|
+
.exists(credentialPath)
|
|
83
|
+
.pipe(
|
|
84
|
+
Effect.mapError((cause) => commandError("Could not check the saved login.", cause)),
|
|
85
|
+
))
|
|
86
|
+
) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
const text = yield* fileSystem
|
|
90
|
+
.readFileString(credentialPath)
|
|
91
|
+
.pipe(Effect.mapError((cause) => commandError("Could not read the saved login.", cause)));
|
|
92
|
+
const credential = yield* Schema.decodeEffect(Schema.fromJsonString(Credential))(text).pipe(
|
|
93
|
+
Effect.mapError((cause) => commandError("The saved login is invalid.", cause)),
|
|
94
|
+
);
|
|
95
|
+
return {
|
|
96
|
+
authUrl: credential.authUrl,
|
|
97
|
+
token: Redacted.make(credential.token),
|
|
98
|
+
} satisfies StoredCredential;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const save = Effect.fn("AuthCredentialStore.save")(function* (credential: StoredCredential) {
|
|
102
|
+
const value = {
|
|
103
|
+
formatVersion: 1,
|
|
104
|
+
authUrl: credential.authUrl,
|
|
105
|
+
token: Redacted.value(credential.token),
|
|
106
|
+
} satisfies Credential;
|
|
107
|
+
const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(Credential))(value).pipe(
|
|
108
|
+
Effect.mapError((cause) => commandError("Could not encode the login.", cause)),
|
|
109
|
+
);
|
|
110
|
+
yield* fileSystem
|
|
111
|
+
.makeDirectory(directory, { mode: 0o700, recursive: true })
|
|
112
|
+
.pipe(
|
|
113
|
+
Effect.mapError((cause) =>
|
|
114
|
+
commandError("Could not create the config directory.", cause),
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
yield* Effect.scoped(
|
|
118
|
+
Effect.gen(function* () {
|
|
119
|
+
const temporaryPath = yield* fileSystem.makeTempFileScoped({
|
|
120
|
+
directory,
|
|
121
|
+
prefix: ".auth-",
|
|
122
|
+
suffix: ".json",
|
|
123
|
+
});
|
|
124
|
+
yield* fileSystem.writeFileString(temporaryPath, `${encoded}\n`, { mode: 0o600 });
|
|
125
|
+
yield* fileSystem.rename(temporaryPath, credentialPath);
|
|
126
|
+
}),
|
|
127
|
+
).pipe(Effect.mapError((cause) => commandError("Could not save the login.", cause)));
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const remove = Effect.fn("AuthCredentialStore.remove")(function* () {
|
|
131
|
+
if (
|
|
132
|
+
!(yield* fileSystem
|
|
133
|
+
.exists(credentialPath)
|
|
134
|
+
.pipe(
|
|
135
|
+
Effect.mapError((cause) => commandError("Could not check the saved login.", cause)),
|
|
136
|
+
))
|
|
137
|
+
) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
yield* fileSystem
|
|
141
|
+
.remove(credentialPath)
|
|
142
|
+
.pipe(
|
|
143
|
+
Effect.mapError((cause) => commandError("Could not remove the saved login.", cause)),
|
|
144
|
+
);
|
|
145
|
+
return true;
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return AuthCredentialStore.of({ load: load(), remove: remove(), save });
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const makeClient = (authUrl: URL) =>
|
|
154
|
+
createAuthClient({
|
|
155
|
+
basePath: "/v1",
|
|
156
|
+
baseURL: authUrl.href,
|
|
157
|
+
plugins: [deviceAuthorizationClient(), organizationClient()],
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const authorization = (token: Redacted.Redacted<string>) => ({
|
|
161
|
+
Authorization: `Bearer ${Redacted.value(token)}`,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const responseError = (error: { readonly error_description?: string; readonly message?: string }) =>
|
|
165
|
+
error.error_description ?? error.message ?? "Authentication failed.";
|
|
166
|
+
|
|
167
|
+
export const login = Effect.fn("Auth.login")(function* () {
|
|
168
|
+
const store = yield* AuthCredentialStore;
|
|
169
|
+
const terminal = yield* Terminal.Terminal;
|
|
170
|
+
const authUrl = yield* Config.schema(AuthUrl, "IGNOTUM_AUTH_URL").pipe(
|
|
171
|
+
Config.withDefault(new URL("https://auth.ignotum.cloud")),
|
|
172
|
+
Effect.mapError((cause) => commandError("The auth URL is invalid.", cause)),
|
|
173
|
+
);
|
|
174
|
+
const client = makeClient(authUrl);
|
|
175
|
+
const existing = yield* store.load;
|
|
176
|
+
if (existing !== undefined && existing.authUrl.href === authUrl.href) {
|
|
177
|
+
const result = yield* Effect.tryPromise({
|
|
178
|
+
try: () => client.getSession({ fetchOptions: { headers: authorization(existing.token) } }),
|
|
179
|
+
catch: (cause) => commandError("Could not check the saved login.", cause),
|
|
180
|
+
});
|
|
181
|
+
if (result.data?.user !== undefined) {
|
|
182
|
+
return {
|
|
183
|
+
email: result.data.user.email,
|
|
184
|
+
verificationUrl: undefined,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const started = yield* Effect.tryPromise({
|
|
190
|
+
try: () => client.device.code({ client_id: "ignotum-cli", scope: "openid profile email" }),
|
|
191
|
+
catch: (cause) => commandError("Could not start login.", cause),
|
|
192
|
+
});
|
|
193
|
+
if (started.data === null) {
|
|
194
|
+
return yield* commandError(responseError(started.error));
|
|
195
|
+
}
|
|
196
|
+
const url = started.data.verification_uri_complete ?? started.data.verification_uri;
|
|
197
|
+
yield* terminal.display(
|
|
198
|
+
`Open ${started.data.verification_uri}\nCode: ${started.data.user_code}\n\nWaiting for approval...\n`,
|
|
199
|
+
);
|
|
200
|
+
yield* Effect.tryPromise({
|
|
201
|
+
// @effect-diagnostics-next-line asyncFunction:off Dynamic import keeps browser-only code out of other CLI commands.
|
|
202
|
+
try: async () => (await import("open")).default(url),
|
|
203
|
+
catch: (cause) => commandError("Could not open the browser.", cause),
|
|
204
|
+
}).pipe(Effect.ignore);
|
|
205
|
+
|
|
206
|
+
let interval = started.data.interval ?? 5;
|
|
207
|
+
while (true) {
|
|
208
|
+
yield* Effect.sleep(`${interval} seconds`);
|
|
209
|
+
const polled = yield* Effect.tryPromise({
|
|
210
|
+
try: () =>
|
|
211
|
+
client.device.token({
|
|
212
|
+
client_id: "ignotum-cli",
|
|
213
|
+
device_code: started.data.device_code,
|
|
214
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
215
|
+
}),
|
|
216
|
+
catch: (cause) => commandError("Login polling failed.", cause),
|
|
217
|
+
});
|
|
218
|
+
if (polled.data?.access_token !== undefined) {
|
|
219
|
+
const token = Redacted.make(polled.data.access_token);
|
|
220
|
+
const session = yield* Effect.tryPromise({
|
|
221
|
+
try: () => client.getSession({ fetchOptions: { headers: authorization(token) } }),
|
|
222
|
+
catch: (cause) => commandError("Could not read the new session.", cause),
|
|
223
|
+
});
|
|
224
|
+
if (session.data?.user === undefined) {
|
|
225
|
+
return yield* commandError("The auth service returned an invalid session.");
|
|
226
|
+
}
|
|
227
|
+
yield* store.save({ authUrl, token });
|
|
228
|
+
return { email: session.data.user.email, verificationUrl: url };
|
|
229
|
+
}
|
|
230
|
+
switch (polled.error?.error) {
|
|
231
|
+
case "authorization_pending":
|
|
232
|
+
break;
|
|
233
|
+
case "slow_down":
|
|
234
|
+
interval += 5;
|
|
235
|
+
break;
|
|
236
|
+
case "access_denied":
|
|
237
|
+
return yield* commandError("Login was denied.");
|
|
238
|
+
case "expired_token":
|
|
239
|
+
return yield* commandError("The login code expired. Run login again.");
|
|
240
|
+
default:
|
|
241
|
+
return yield* commandError(
|
|
242
|
+
polled.error === null ? "Login failed." : responseError(polled.error),
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
export const logout = Effect.fn("Auth.logout")(function* () {
|
|
249
|
+
const store = yield* AuthCredentialStore;
|
|
250
|
+
const credential = yield* store.load;
|
|
251
|
+
if (credential === undefined) return false;
|
|
252
|
+
const client = makeClient(credential.authUrl);
|
|
253
|
+
const result = yield* Effect.tryPromise({
|
|
254
|
+
try: () => client.signOut({ fetchOptions: { headers: authorization(credential.token) } }),
|
|
255
|
+
catch: (cause) => commandError("Could not revoke the session.", cause),
|
|
256
|
+
});
|
|
257
|
+
if (result.error !== null) {
|
|
258
|
+
return yield* commandError(responseError(result.error));
|
|
259
|
+
}
|
|
260
|
+
yield* store.remove;
|
|
261
|
+
return true;
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
export const status = Effect.fn("Auth.status")(function* () {
|
|
265
|
+
const store = yield* AuthCredentialStore;
|
|
266
|
+
const credential = yield* store.load;
|
|
267
|
+
if (credential === undefined) return undefined;
|
|
268
|
+
const client = makeClient(credential.authUrl);
|
|
269
|
+
const session = yield* Effect.tryPromise({
|
|
270
|
+
try: () => client.getSession({ fetchOptions: { headers: authorization(credential.token) } }),
|
|
271
|
+
catch: (cause) => commandError("Could not check login status.", cause),
|
|
272
|
+
});
|
|
273
|
+
if (session.data?.user === undefined) return undefined;
|
|
274
|
+
const teams = yield* Effect.tryPromise({
|
|
275
|
+
try: () =>
|
|
276
|
+
client.organization.list({
|
|
277
|
+
fetchOptions: { headers: authorization(credential.token) },
|
|
278
|
+
}),
|
|
279
|
+
catch: (cause) => commandError("Could not load the team.", cause),
|
|
280
|
+
});
|
|
281
|
+
return {
|
|
282
|
+
email: session.data.user.email,
|
|
283
|
+
name: session.data.user.name,
|
|
284
|
+
teamName: teams.data?.[0]?.name,
|
|
285
|
+
};
|
|
286
|
+
});
|
package/src/cli/command.ts
CHANGED
|
@@ -6,6 +6,12 @@ import { IdGenerator } from "@ignotum/shared/id";
|
|
|
6
6
|
|
|
7
7
|
import packageJson from "../../package.json" with { type: "json" };
|
|
8
8
|
import { generate } from "./codegen.js";
|
|
9
|
+
import {
|
|
10
|
+
AuthCredentialStore,
|
|
11
|
+
login as runLogin,
|
|
12
|
+
logout as runLogout,
|
|
13
|
+
status as readAuthStatus,
|
|
14
|
+
} from "./auth-client.js";
|
|
9
15
|
import { ControlClient } from "./control-client.js";
|
|
10
16
|
import { deploy as runDeploy } from "./deploy.js";
|
|
11
17
|
import { resetDevDatabase } from "../dev-runtime/dev-database.js";
|
|
@@ -15,6 +21,58 @@ import { installDependencies } from "./package-manager.js";
|
|
|
15
21
|
|
|
16
22
|
const DevPort = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65_535 }));
|
|
17
23
|
|
|
24
|
+
const authLogin = Command.make(
|
|
25
|
+
"login",
|
|
26
|
+
{},
|
|
27
|
+
Effect.fn("auth login")(function* () {
|
|
28
|
+
const terminal = yield* Terminal.Terminal;
|
|
29
|
+
const result = yield* runLogin().pipe(
|
|
30
|
+
Effect.mapError((error) =>
|
|
31
|
+
CliError.UserError.make({ cause: error, userMessage: error.message }),
|
|
32
|
+
),
|
|
33
|
+
);
|
|
34
|
+
yield* terminal.display(`Signed in as ${result.email}.\n`);
|
|
35
|
+
}),
|
|
36
|
+
).pipe(Command.withDescription("Sign in to Ignotum with GitHub."));
|
|
37
|
+
|
|
38
|
+
const authLogout = Command.make(
|
|
39
|
+
"logout",
|
|
40
|
+
{},
|
|
41
|
+
Effect.fn("auth logout")(function* () {
|
|
42
|
+
const terminal = yield* Terminal.Terminal;
|
|
43
|
+
const removed = yield* runLogout().pipe(
|
|
44
|
+
Effect.mapError((error) =>
|
|
45
|
+
CliError.UserError.make({ cause: error, userMessage: error.message }),
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
yield* terminal.display(removed ? "Signed out.\n" : "Not signed in.\n");
|
|
49
|
+
}),
|
|
50
|
+
).pipe(Command.withDescription("Revoke and remove the saved login."));
|
|
51
|
+
|
|
52
|
+
const authStatus = Command.make(
|
|
53
|
+
"status",
|
|
54
|
+
{},
|
|
55
|
+
Effect.fn("auth status")(function* () {
|
|
56
|
+
const terminal = yield* Terminal.Terminal;
|
|
57
|
+
const current = yield* readAuthStatus().pipe(
|
|
58
|
+
Effect.mapError((error) =>
|
|
59
|
+
CliError.UserError.make({ cause: error, userMessage: error.message }),
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
if (current === undefined) {
|
|
63
|
+
yield* terminal.display("Not signed in.\n");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const team = current.teamName === undefined ? "" : `\nTeam: ${current.teamName}`;
|
|
67
|
+
yield* terminal.display(`Signed in as ${current.name} (${current.email}).${team}\n`);
|
|
68
|
+
}),
|
|
69
|
+
).pipe(Command.withDescription("Show the current Ignotum login."));
|
|
70
|
+
|
|
71
|
+
const auth = Command.make("auth").pipe(
|
|
72
|
+
Command.withDescription("Manage your Ignotum login."),
|
|
73
|
+
Command.withSubcommands([authLogin, authLogout, authStatus]),
|
|
74
|
+
);
|
|
75
|
+
|
|
18
76
|
const codegen = Command.make(
|
|
19
77
|
"codegen",
|
|
20
78
|
{},
|
|
@@ -172,7 +230,7 @@ const newApp = Command.make(
|
|
|
172
230
|
);
|
|
173
231
|
|
|
174
232
|
const ignotum = Command.make("ignotum").pipe(
|
|
175
|
-
Command.withSubcommands([newApp, install, codegen, dev, deploy]),
|
|
233
|
+
Command.withSubcommands([newApp, install, codegen, dev, deploy, auth]),
|
|
176
234
|
);
|
|
177
235
|
|
|
178
236
|
export const main = (): void => {
|
|
@@ -180,7 +238,12 @@ export const main = (): void => {
|
|
|
180
238
|
Command.run({ version: packageJson.version }),
|
|
181
239
|
// @effect-diagnostics-next-line strictEffectProvide:off
|
|
182
240
|
Effect.provide(
|
|
183
|
-
Layer.mergeAll(
|
|
241
|
+
Layer.mergeAll(
|
|
242
|
+
NodeServices.layer,
|
|
243
|
+
NodeHttpClient.layerUndici,
|
|
244
|
+
IdGenerator.layer,
|
|
245
|
+
AuthCredentialStore.layer.pipe(Layer.provide(NodeServices.layer)),
|
|
246
|
+
),
|
|
184
247
|
),
|
|
185
248
|
NodeRuntime.runMain,
|
|
186
249
|
);
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
} from "effect";
|
|
42
42
|
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
|
|
43
43
|
import type { HttpClientRequest as HttpRequest } from "effect/unstable/http/HttpClientRequest";
|
|
44
|
+
import { AuthCommandError, AuthCredentialStore } from "./auth-client.js";
|
|
44
45
|
|
|
45
46
|
export interface HostedControlConfig {
|
|
46
47
|
readonly apiUrl: URL;
|
|
@@ -69,11 +70,19 @@ export class HostedControlConfiguration extends Context.Service<
|
|
|
69
70
|
>()("ignotum/cli/control-client/HostedControlConfiguration") {
|
|
70
71
|
static readonly layer = Layer.effect(
|
|
71
72
|
HostedControlConfiguration,
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
Effect.gen(function* () {
|
|
74
|
+
const credential = yield* (yield* AuthCredentialStore).load;
|
|
75
|
+
if (credential === undefined) {
|
|
76
|
+
return yield* AuthCommandError.make({
|
|
77
|
+
message: "Run 'npx ignotum auth login' first.",
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
apiUrl: yield* Config.schema(HostedApiUrl, "IGNOTUM_API_URL").pipe(
|
|
82
|
+
Config.withDefault(new URL("https://api.ignotum.cloud")),
|
|
83
|
+
),
|
|
84
|
+
token: credential.token,
|
|
85
|
+
};
|
|
77
86
|
}),
|
|
78
87
|
);
|
|
79
88
|
}
|
|
@@ -133,7 +142,11 @@ export class ControlClient extends Context.Service<ControlClient, ControlClientS
|
|
|
133
142
|
"ignotum/cli/control-client/ControlClient",
|
|
134
143
|
) {
|
|
135
144
|
static get layer() {
|
|
136
|
-
return controlClientLayer.pipe(
|
|
145
|
+
return controlClientLayer.pipe(
|
|
146
|
+
Layer.provideMerge(
|
|
147
|
+
HostedControlConfiguration.layer.pipe(Layer.provide(AuthCredentialStore.layer)),
|
|
148
|
+
),
|
|
149
|
+
);
|
|
137
150
|
}
|
|
138
151
|
}
|
|
139
152
|
|
package/src/cli/new-app.ts
CHANGED
|
@@ -209,9 +209,10 @@ and updates them when the schema or server functions change.
|
|
|
209
209
|
|
|
210
210
|
## Deploy
|
|
211
211
|
|
|
212
|
-
|
|
212
|
+
Sign in, then choose a unique slug on the first deployment:
|
|
213
213
|
|
|
214
214
|
\`\`\`sh
|
|
215
|
+
npx ignotum auth login
|
|
215
216
|
npx ignotum deploy --app my-app
|
|
216
217
|
\`\`\`
|
|
217
218
|
|
package/src/client/errors.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** @effect-diagnostics globalConsole:skip-file */
|
|
2
2
|
import { Cause, Effect, ErrorReporter, Schema } from "effect";
|
|
3
3
|
|
|
4
|
-
import { FunctionAddress, Operation
|
|
4
|
+
import { ErrorCode, FunctionAddress, Operation } from "@ignotum/contracts/runtime/sync";
|
|
5
5
|
|
|
6
6
|
export class ConnectionUnavailable extends Schema.TaggedError<ConnectionUnavailable>()(
|
|
7
7
|
"ConnectionUnavailable",
|
|
@@ -36,21 +36,18 @@ export class InvalidServerMessage extends Schema.TaggedError<InvalidServerMessag
|
|
|
36
36
|
},
|
|
37
37
|
) {}
|
|
38
38
|
|
|
39
|
-
export class
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
operation: Schema.optional(Operation),
|
|
45
|
-
},
|
|
46
|
-
) {}
|
|
39
|
+
export class ServerError extends Schema.TaggedError<ServerError>()("ServerError", {
|
|
40
|
+
code: ErrorCode,
|
|
41
|
+
message: Schema.String,
|
|
42
|
+
operation: Schema.optional(Operation),
|
|
43
|
+
}) {}
|
|
47
44
|
|
|
48
45
|
export const ClientInfrastructureError = Schema.Union([
|
|
49
46
|
ConnectionUnavailable,
|
|
50
47
|
InvalidClientMessage,
|
|
51
48
|
InvalidMutationArguments,
|
|
52
49
|
InvalidServerMessage,
|
|
53
|
-
|
|
50
|
+
ServerError,
|
|
54
51
|
]);
|
|
55
52
|
export type ClientInfrastructureError = typeof ClientInfrastructureError.Type;
|
|
56
53
|
|
package/src/client/files.ts
CHANGED
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
makeFileValue,
|
|
11
11
|
type FileFormat,
|
|
12
12
|
type FileValue,
|
|
13
|
-
type RuntimeFileOccurrence,
|
|
14
13
|
} from "@ignotum/contracts/schema/file";
|
|
15
14
|
import { IdGenerator } from "@ignotum/shared/id";
|
|
16
15
|
import { Effect, Predicate } from "effect";
|
|
@@ -28,30 +27,22 @@ export const Files = {
|
|
|
28
27
|
const formatByMime: Partial<Record<string, FileFormat>> = {};
|
|
29
28
|
for (const format of fileFormats) formatByMime[fileMimeTypes[format]] = format;
|
|
30
29
|
|
|
31
|
-
interface NativeOccurrence {
|
|
32
|
-
readonly path: ReadonlyArray<string | number>;
|
|
33
|
-
readonly value: File;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
30
|
const collectNativeFiles = (
|
|
37
31
|
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- This is the recursive parser for generated mutation arguments and native File values.
|
|
38
32
|
value: unknown,
|
|
39
|
-
|
|
40
|
-
occurrences: Array<NativeOccurrence>,
|
|
33
|
+
files: Array<File>,
|
|
41
34
|
): void => {
|
|
42
35
|
if (value instanceof File) {
|
|
43
|
-
|
|
36
|
+
files.push(value);
|
|
44
37
|
return;
|
|
45
38
|
}
|
|
46
39
|
if (isFileValue(value) || Predicate.isDate(value)) return;
|
|
47
40
|
if (globalThis.Array.isArray(value)) {
|
|
48
|
-
for (const
|
|
49
|
-
collectNativeFiles(child, [...path, index], occurrences);
|
|
41
|
+
for (const child of value) collectNativeFiles(child, files);
|
|
50
42
|
return;
|
|
51
43
|
}
|
|
52
44
|
if (!Predicate.isObject(value)) return;
|
|
53
|
-
for (const
|
|
54
|
-
collectNativeFiles(child, [...path, key], occurrences);
|
|
45
|
+
for (const child of Object.values(value)) collectNativeFiles(child, files);
|
|
55
46
|
};
|
|
56
47
|
|
|
57
48
|
const replaceNativeFiles = (
|
|
@@ -73,7 +64,7 @@ const replaceNativeFiles = (
|
|
|
73
64
|
export interface PreparedMutationArguments {
|
|
74
65
|
readonly value: unknown;
|
|
75
66
|
readonly nativeFiles: ReadonlyMap<FileId, File>;
|
|
76
|
-
readonly
|
|
67
|
+
readonly fileIds: ReadonlyArray<FileId>;
|
|
77
68
|
}
|
|
78
69
|
|
|
79
70
|
export const prepareMutationArguments = Effect.fn("SyncClient.prepareMutationArguments")(function* (
|
|
@@ -81,9 +72,9 @@ export const prepareMutationArguments = Effect.fn("SyncClient.prepareMutationArg
|
|
|
81
72
|
input: unknown,
|
|
82
73
|
) {
|
|
83
74
|
const ids = yield* IdGenerator;
|
|
84
|
-
const native:
|
|
85
|
-
collectNativeFiles(input,
|
|
86
|
-
const unique = new Set(native
|
|
75
|
+
const native: File[] = [];
|
|
76
|
+
collectNativeFiles(input, native);
|
|
77
|
+
const unique = new Set(native);
|
|
87
78
|
if (unique.size > fileLimits.filesPerMutation) {
|
|
88
79
|
throw new Error(`A mutation may upload at most ${fileLimits.filesPerMutation} files.`);
|
|
89
80
|
}
|
|
@@ -111,58 +102,52 @@ export const prepareMutationArguments = Effect.fn("SyncClient.prepareMutationArg
|
|
|
111
102
|
nativeFiles.set(id, file);
|
|
112
103
|
}
|
|
113
104
|
const value = replaceNativeFiles(input, replacements);
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
file: {
|
|
120
|
-
id: fileIdOf(replacement),
|
|
121
|
-
format: replacement.format,
|
|
122
|
-
name: replacement.name,
|
|
123
|
-
size: replacement.size,
|
|
124
|
-
},
|
|
125
|
-
} satisfies RuntimeFileOccurrence;
|
|
126
|
-
});
|
|
127
|
-
return { value, nativeFiles, occurrences } satisfies PreparedMutationArguments;
|
|
105
|
+
return {
|
|
106
|
+
value,
|
|
107
|
+
nativeFiles,
|
|
108
|
+
fileIds: Array.from(nativeFiles.keys()),
|
|
109
|
+
} satisfies PreparedMutationArguments;
|
|
128
110
|
});
|
|
129
111
|
|
|
130
|
-
interface
|
|
112
|
+
interface FileGrant {
|
|
113
|
+
readonly id: FileId;
|
|
131
114
|
readonly url: string;
|
|
132
115
|
}
|
|
133
116
|
|
|
134
|
-
const pathKey = (path: ReadonlyArray<string | number>): string => JSON.stringify(path);
|
|
135
|
-
|
|
136
117
|
// @effect-diagnostics-next-line missingPipeableSignature:off File grant attachment naturally takes the decoded value first.
|
|
137
118
|
export const applyFileGrants = (
|
|
138
|
-
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- Query result types are erased at the wire decoding boundary and checked against grant
|
|
119
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- Query result types are erased at the wire decoding boundary and checked against grant identities below.
|
|
139
120
|
value: unknown,
|
|
140
|
-
grants: ReadonlyArray<
|
|
121
|
+
grants: ReadonlyArray<FileGrant>,
|
|
141
122
|
// oxlint-disable-next-line anti-slop/no-unknown-returns -- The traversal preserves the decoded query result shape while attaching private symbols.
|
|
142
123
|
): unknown => {
|
|
143
|
-
const
|
|
124
|
+
const byId = new Map<FileId, string>();
|
|
125
|
+
for (const grant of grants) {
|
|
126
|
+
if (byId.has(grant.id)) throw new Error("A query file grant ID is duplicated.");
|
|
127
|
+
byId.set(grant.id, grant.url);
|
|
128
|
+
}
|
|
129
|
+
const matched = new Set<FileId>();
|
|
144
130
|
const visit = (
|
|
145
|
-
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- This recursive visitor
|
|
131
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- This recursive visitor identifies file values before attaching grants.
|
|
146
132
|
current: unknown,
|
|
147
|
-
path: ReadonlyArray<string | number>,
|
|
148
133
|
// oxlint-disable-next-line anti-slop/no-unknown-returns -- Recursive branches preserve their input shape.
|
|
149
134
|
): unknown => {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
return grantFileValue(current,
|
|
135
|
+
if (isFileValue(current)) {
|
|
136
|
+
const id = fileIdOf(current);
|
|
137
|
+
const url = byId.get(id);
|
|
138
|
+
if (url === undefined) return current;
|
|
139
|
+
matched.add(id);
|
|
140
|
+
return grantFileValue(current, url);
|
|
156
141
|
}
|
|
157
142
|
if (globalThis.Array.isArray(current)) {
|
|
158
|
-
return current.map(
|
|
143
|
+
return current.map(visit);
|
|
159
144
|
}
|
|
160
|
-
if (!Predicate.isObject(current) || Predicate.isDate(current)
|
|
145
|
+
if (!Predicate.isObject(current) || Predicate.isDate(current)) {
|
|
161
146
|
return current;
|
|
162
147
|
}
|
|
163
|
-
return Object.fromEntries(
|
|
164
|
-
Object.entries(current).map(([key, child]) => [key, visit(child, [...path, key])]),
|
|
165
|
-
);
|
|
148
|
+
return Object.fromEntries(Object.entries(current).map(([key, child]) => [key, visit(child)]));
|
|
166
149
|
};
|
|
167
|
-
|
|
150
|
+
const granted = visit(value);
|
|
151
|
+
if (matched.size !== byId.size) throw new Error("A query file grant has no result value.");
|
|
152
|
+
return granted;
|
|
168
153
|
};
|