@go-labs-sg/gosh 0.4.2 → 0.6.1
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 +73 -0
- package/command-manifest.json +46 -5
- package/command-reference.md +21 -5
- package/dist/index.js +249 -37
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -72,6 +72,65 @@ Commands use `--input '<json>'` for the exact validated tRPC input object. This
|
|
|
72
72
|
|
|
73
73
|
The command registry covers every callable GOSH tRPC procedure: organizer and global-admin operations, organization invitations and membership, player workflows, public results/login procedures, uploads, and realtime subscriptions. The compile-time registry test fails whenever a new API procedure is added without a CLI command.
|
|
74
74
|
|
|
75
|
+
### Templates and organization scope
|
|
76
|
+
|
|
77
|
+
Template commands use the selected organization as their scope. Provide its ID in
|
|
78
|
+
the input when listing or retrieving a template:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
gosh template list --input '{"organizationId":"acme"}'
|
|
82
|
+
gosh template get --input '{"organizationId":"acme","templateId":"template-id"}'
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Organization members can use only templates from that organization. Global admins
|
|
86
|
+
can inspect templates from all organizations with `allOrganizations: true`, then
|
|
87
|
+
use any returned template when creating an event for a chosen organization:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
gosh template list --input '{"allOrganizations":true}'
|
|
91
|
+
gosh event create-from-template \
|
|
92
|
+
--input '{"name":"Team challenge","organizationId":"acme","templateId":"template-id"}' \
|
|
93
|
+
--target template-id \
|
|
94
|
+
--allow-state-change
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Shared stations and team routes
|
|
98
|
+
|
|
99
|
+
Create each station once for an event. In a team event, link the same station to
|
|
100
|
+
each team's route with its own order. `team set-station-routes` replaces one
|
|
101
|
+
team's complete route, so provide every station that team should see in order:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
gosh team station-routes --input '{"eventId":"event-id"}'
|
|
105
|
+
gosh team set-station-routes \
|
|
106
|
+
--input '{"eventId":"event-id","teamId":"team-red","stationIds":["station-2","station-1"]}' \
|
|
107
|
+
--target team-red \
|
|
108
|
+
--allow-state-change
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
To update an existing route's ordering without changing its station links, use
|
|
112
|
+
`gosh station reorder` with the team as `scopeTeamId`.
|
|
113
|
+
|
|
114
|
+
`gosh station clone` preserves the source station's team links when
|
|
115
|
+
`targetTeamId` is omitted, appending the clone to each linked team's route.
|
|
116
|
+
Provide a team ID to target only that team, or `null` to add the clone to the
|
|
117
|
+
event catalog without explicit team links.
|
|
118
|
+
|
|
119
|
+
When creating or replacing a template, each station can include `teamOrders` to
|
|
120
|
+
preserve its route order for specific teams. `teamOrder` identifies the matching
|
|
121
|
+
value in that template team's `order` field:
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"stations": [
|
|
126
|
+
{
|
|
127
|
+
"name": "Welcome station",
|
|
128
|
+
"teamOrders": [{ "teamOrder": 0, "order": 2 }]
|
|
129
|
+
}
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
75
134
|
### Local file uploads
|
|
76
135
|
|
|
77
136
|
Upload commands add the local file metadata to the supplied API input, request a permission-scoped signed URL, and upload the bytes directly to storage without sending the GOSH CLI access token to storage:
|
|
@@ -147,3 +206,17 @@ Without the allow flags, an interactive terminal prints a workflow preview and r
|
|
|
147
206
|
## Output
|
|
148
207
|
|
|
149
208
|
One-shot commands write one JSON envelope to stdout. Watch commands write newline-delimited JSON envelopes. Failures and opt-in `--debug` diagnostics go to stderr, and known secrets, payloads, join codes, share tokens, and signed URLs are redacted. `--quiet` or `-q` suppresses diagnostics.
|
|
209
|
+
|
|
210
|
+
## Release verification
|
|
211
|
+
|
|
212
|
+
The `gosh-cli-v*` release workflow runs
|
|
213
|
+
`bun run packages/scripts/monorepo/verify-cli-release-baseline.ts gosh` to check
|
|
214
|
+
and typecheck the shared credential store, typecheck the retained test setup,
|
|
215
|
+
and check, typecheck, and build the GOSH CLI. It then validates the packed
|
|
216
|
+
artifact and runs both the installed entry point and `gosh version` before
|
|
217
|
+
publishing.
|
|
218
|
+
|
|
219
|
+
Behavior changes are verified with focused temporary tests before the PR,
|
|
220
|
+
following the repository's temporary-test workflow in `AGENTS.md`. Those tests
|
|
221
|
+
are removed before delivery, so the release workflow does not invoke the retired
|
|
222
|
+
credential-store, architecture, or product regression suites.
|
package/command-manifest.json
CHANGED
|
@@ -750,7 +750,7 @@
|
|
|
750
750
|
"path": ["station", "reorder"],
|
|
751
751
|
"procedure": "event.reorderStations",
|
|
752
752
|
"requiresAuth": true,
|
|
753
|
-
"summary": "Reorder
|
|
753
|
+
"summary": "Reorder shared stations, or a selected team's station route when scopeTeamId is supplied.",
|
|
754
754
|
"targetKeys": [
|
|
755
755
|
"id",
|
|
756
756
|
"memberId",
|
|
@@ -912,6 +912,15 @@
|
|
|
912
912
|
"requiresAuth": true,
|
|
913
913
|
"summary": "List teams and their current members."
|
|
914
914
|
},
|
|
915
|
+
{
|
|
916
|
+
"effects": [],
|
|
917
|
+
"input": "json",
|
|
918
|
+
"operation": "query",
|
|
919
|
+
"path": ["team", "station-routes"],
|
|
920
|
+
"procedure": "team.getStationRoutes",
|
|
921
|
+
"requiresAuth": true,
|
|
922
|
+
"summary": "List each team's linked stations and team-specific route order."
|
|
923
|
+
},
|
|
915
924
|
{
|
|
916
925
|
"effects": ["state-change"],
|
|
917
926
|
"input": "json",
|
|
@@ -1104,6 +1113,38 @@
|
|
|
1104
1113
|
"name"
|
|
1105
1114
|
]
|
|
1106
1115
|
},
|
|
1116
|
+
{
|
|
1117
|
+
"effects": ["state-change"],
|
|
1118
|
+
"input": "json",
|
|
1119
|
+
"operation": "mutation",
|
|
1120
|
+
"path": ["team", "set-station-routes"],
|
|
1121
|
+
"procedure": "team.setStationRoutes",
|
|
1122
|
+
"requiresAuth": true,
|
|
1123
|
+
"summary": "Replace a team's linked station route and set its order.",
|
|
1124
|
+
"targetKeys": [
|
|
1125
|
+
"id",
|
|
1126
|
+
"memberId",
|
|
1127
|
+
"invitationId",
|
|
1128
|
+
"broadcastId",
|
|
1129
|
+
"rewardId",
|
|
1130
|
+
"submissionId",
|
|
1131
|
+
"participantId",
|
|
1132
|
+
"participantIds",
|
|
1133
|
+
"missionId",
|
|
1134
|
+
"stationId",
|
|
1135
|
+
"teamId",
|
|
1136
|
+
"templateId",
|
|
1137
|
+
"eventId",
|
|
1138
|
+
"organizationId",
|
|
1139
|
+
"userId",
|
|
1140
|
+
"key",
|
|
1141
|
+
"uploadId",
|
|
1142
|
+
"email",
|
|
1143
|
+
"joinCode",
|
|
1144
|
+
"slug",
|
|
1145
|
+
"name"
|
|
1146
|
+
]
|
|
1147
|
+
},
|
|
1107
1148
|
{
|
|
1108
1149
|
"effects": [],
|
|
1109
1150
|
"input": "json",
|
|
@@ -1111,7 +1152,7 @@
|
|
|
1111
1152
|
"path": ["template", "list"],
|
|
1112
1153
|
"procedure": "template.list",
|
|
1113
1154
|
"requiresAuth": true,
|
|
1114
|
-
"summary": "List
|
|
1155
|
+
"summary": "List templates visible in an organization; global admins can include every organization."
|
|
1115
1156
|
},
|
|
1116
1157
|
{
|
|
1117
1158
|
"effects": [],
|
|
@@ -1120,7 +1161,7 @@
|
|
|
1120
1161
|
"path": ["template", "get"],
|
|
1121
1162
|
"procedure": "template.getById",
|
|
1122
1163
|
"requiresAuth": true,
|
|
1123
|
-
"summary": "Get a template
|
|
1164
|
+
"summary": "Get a template visible in the requested organization; global admins can get any template."
|
|
1124
1165
|
},
|
|
1125
1166
|
{
|
|
1126
1167
|
"effects": ["state-change"],
|
|
@@ -1129,7 +1170,7 @@
|
|
|
1129
1170
|
"path": ["template", "create"],
|
|
1130
1171
|
"procedure": "template.create",
|
|
1131
1172
|
"requiresAuth": true,
|
|
1132
|
-
"summary": "Create an organization template.",
|
|
1173
|
+
"summary": "Create an organization template, including shared stations with optional per-team route order.",
|
|
1133
1174
|
"targetKeys": ["organizationId", "name"]
|
|
1134
1175
|
},
|
|
1135
1176
|
{
|
|
@@ -1139,7 +1180,7 @@
|
|
|
1139
1180
|
"path": ["template", "update"],
|
|
1140
1181
|
"procedure": "template.update",
|
|
1141
1182
|
"requiresAuth": true,
|
|
1142
|
-
"summary": "Replace a template's setup.",
|
|
1183
|
+
"summary": "Replace a template's setup, including shared stations with optional per-team route order.",
|
|
1143
1184
|
"targetKeys": [
|
|
1144
1185
|
"id",
|
|
1145
1186
|
"memberId",
|
package/command-reference.md
CHANGED
|
@@ -316,7 +316,7 @@ Archive a station.
|
|
|
316
316
|
|
|
317
317
|
## `gosh station reorder`
|
|
318
318
|
|
|
319
|
-
Reorder
|
|
319
|
+
Reorder shared stations, or a selected team's station route when scopeTeamId is supplied.
|
|
320
320
|
|
|
321
321
|
- Authentication: browser-authenticated CLI session required
|
|
322
322
|
- Input: `--input '<json>'` required
|
|
@@ -362,6 +362,14 @@ List teams and their current members.
|
|
|
362
362
|
- Input: `--input '<json>'` required
|
|
363
363
|
- Effects: read-only
|
|
364
364
|
|
|
365
|
+
## `gosh team station-routes`
|
|
366
|
+
|
|
367
|
+
List each team's linked stations and team-specific route order.
|
|
368
|
+
|
|
369
|
+
- Authentication: browser-authenticated CLI session required
|
|
370
|
+
- Input: `--input '<json>'` required
|
|
371
|
+
- Effects: read-only
|
|
372
|
+
|
|
365
373
|
## `gosh team create`
|
|
366
374
|
|
|
367
375
|
Create a team.
|
|
@@ -410,9 +418,17 @@ Change who can submit missions in team play.
|
|
|
410
418
|
- Input: `--input '<json>'` required
|
|
411
419
|
- Effects: state-change
|
|
412
420
|
|
|
421
|
+
## `gosh team set-station-routes`
|
|
422
|
+
|
|
423
|
+
Replace a team's linked station route and set its order.
|
|
424
|
+
|
|
425
|
+
- Authentication: browser-authenticated CLI session required
|
|
426
|
+
- Input: `--input '<json>'` required
|
|
427
|
+
- Effects: state-change
|
|
428
|
+
|
|
413
429
|
## `gosh template list`
|
|
414
430
|
|
|
415
|
-
List
|
|
431
|
+
List templates visible in an organization; global admins can include every organization.
|
|
416
432
|
|
|
417
433
|
- Authentication: browser-authenticated CLI session required
|
|
418
434
|
- Input: `--input '<json>'` required
|
|
@@ -420,7 +436,7 @@ List system and organization templates.
|
|
|
420
436
|
|
|
421
437
|
## `gosh template get`
|
|
422
438
|
|
|
423
|
-
Get a template
|
|
439
|
+
Get a template visible in the requested organization; global admins can get any template.
|
|
424
440
|
|
|
425
441
|
- Authentication: browser-authenticated CLI session required
|
|
426
442
|
- Input: `--input '<json>'` required
|
|
@@ -428,7 +444,7 @@ Get a template by ID.
|
|
|
428
444
|
|
|
429
445
|
## `gosh template create`
|
|
430
446
|
|
|
431
|
-
Create an organization template.
|
|
447
|
+
Create an organization template, including shared stations with optional per-team route order.
|
|
432
448
|
|
|
433
449
|
- Authentication: browser-authenticated CLI session required
|
|
434
450
|
- Input: `--input '<json>'` required
|
|
@@ -436,7 +452,7 @@ Create an organization template.
|
|
|
436
452
|
|
|
437
453
|
## `gosh template update`
|
|
438
454
|
|
|
439
|
-
Replace a template's setup.
|
|
455
|
+
Replace a template's setup, including shared stations with optional per-team route order.
|
|
440
456
|
|
|
441
457
|
- Authentication: browser-authenticated CLI session required
|
|
442
458
|
- Input: `--input '<json>'` required
|
package/dist/index.js
CHANGED
|
@@ -375,6 +375,20 @@ import_dotenv.config({ path: path.resolve(process.cwd(), ".env"), quiet: true })
|
|
|
375
375
|
// src/cli.ts
|
|
376
376
|
import { readFileSync } from "fs";
|
|
377
377
|
|
|
378
|
+
// ../../cli-auth/src/client/credential-store.ts
|
|
379
|
+
import { createHash as createHash2, randomUUID } from "crypto";
|
|
380
|
+
import {
|
|
381
|
+
chmod,
|
|
382
|
+
lstat,
|
|
383
|
+
mkdir,
|
|
384
|
+
open as open2,
|
|
385
|
+
readFile,
|
|
386
|
+
stat,
|
|
387
|
+
unlink
|
|
388
|
+
} from "fs/promises";
|
|
389
|
+
import { userInfo } from "os";
|
|
390
|
+
import { join } from "path";
|
|
391
|
+
|
|
378
392
|
// ../../cli-auth/src/protocol/constants.ts
|
|
379
393
|
var CLI_ACCESS_TOKEN_TTL_MS = 15 * 60 * 1000;
|
|
380
394
|
var CLI_DEVICE_AUTHORIZATION_TTL_MS = 10 * 60 * 1000;
|
|
@@ -19028,13 +19042,15 @@ var deviceTokenErrorSchema = exports_external.object({
|
|
|
19028
19042
|
"authorization_pending",
|
|
19029
19043
|
"slow_down",
|
|
19030
19044
|
"access_denied",
|
|
19031
|
-
"expired_token"
|
|
19045
|
+
"expired_token",
|
|
19046
|
+
"invalid_grant"
|
|
19032
19047
|
])
|
|
19033
19048
|
}).strict();
|
|
19034
19049
|
var refreshRequestSchema = exports_external.object({ refreshToken: exports_external.string().min(40) }).strict();
|
|
19035
19050
|
var storedCliCredentialsSchema = exports_external.object({
|
|
19036
19051
|
accessToken: exports_external.string().min(1),
|
|
19037
19052
|
accessTokenExpiresAt: dateTimeString,
|
|
19053
|
+
issuer: exports_external.string().url().optional(),
|
|
19038
19054
|
refreshToken: exports_external.string().min(1),
|
|
19039
19055
|
refreshTokenExpiresAt: dateTimeString,
|
|
19040
19056
|
session: cliSessionIdentitySchema
|
|
@@ -19086,6 +19102,77 @@ var defaultSecrets = () => {
|
|
|
19086
19102
|
assertBunRuntime();
|
|
19087
19103
|
return Bun.secrets;
|
|
19088
19104
|
};
|
|
19105
|
+
var refreshLockTimeoutMs = 5 * 60 * 1000;
|
|
19106
|
+
var refreshLockRetryMs = 50;
|
|
19107
|
+
var getCredentialLockDirectoryPath = () => {
|
|
19108
|
+
return join(userInfo().homedir, ".cache", "go-labs-cli", "locks");
|
|
19109
|
+
};
|
|
19110
|
+
var ensurePrivateCredentialLockDirectory = async () => {
|
|
19111
|
+
const directory = getCredentialLockDirectoryPath();
|
|
19112
|
+
await mkdir(directory, { mode: 448, recursive: true });
|
|
19113
|
+
const inspectDirectory = async () => {
|
|
19114
|
+
const directoryStat2 = await lstat(directory);
|
|
19115
|
+
if (directoryStat2.isSymbolicLink() || !directoryStat2.isDirectory()) {
|
|
19116
|
+
throw new Error("The CLI credential lock path is not a private directory.");
|
|
19117
|
+
}
|
|
19118
|
+
const uid = process.getuid?.();
|
|
19119
|
+
if (uid !== undefined && directoryStat2.uid !== uid) {
|
|
19120
|
+
throw new Error("The CLI credential lock directory belongs to another user.");
|
|
19121
|
+
}
|
|
19122
|
+
return directoryStat2;
|
|
19123
|
+
};
|
|
19124
|
+
const directoryStat = await inspectDirectory();
|
|
19125
|
+
if ((directoryStat.mode & 63) !== 0) {
|
|
19126
|
+
await chmod(directory, 448);
|
|
19127
|
+
const securedDirectoryStat = await inspectDirectory();
|
|
19128
|
+
if ((securedDirectoryStat.mode & 63) !== 0) {
|
|
19129
|
+
throw new Error("Unable to secure the CLI credential lock directory.");
|
|
19130
|
+
}
|
|
19131
|
+
}
|
|
19132
|
+
return directory;
|
|
19133
|
+
};
|
|
19134
|
+
var getCredentialLockPath = async (product, account) => {
|
|
19135
|
+
const key = createHash2("sha256").update(`${product}:${account}`).digest("hex");
|
|
19136
|
+
return join(await ensurePrivateCredentialLockDirectory(), `${key}.lock`);
|
|
19137
|
+
};
|
|
19138
|
+
var acquireRefreshLock = async (path2) => {
|
|
19139
|
+
const deadline = Date.now() + refreshLockTimeoutMs;
|
|
19140
|
+
while (Date.now() < deadline) {
|
|
19141
|
+
try {
|
|
19142
|
+
const handle = await open2(path2, "wx");
|
|
19143
|
+
const owner = randomUUID();
|
|
19144
|
+
try {
|
|
19145
|
+
await handle.writeFile(owner);
|
|
19146
|
+
} catch (cause) {
|
|
19147
|
+
await handle.close().catch(() => {
|
|
19148
|
+
return;
|
|
19149
|
+
});
|
|
19150
|
+
await unlink(path2).catch(() => {
|
|
19151
|
+
return;
|
|
19152
|
+
});
|
|
19153
|
+
throw cause;
|
|
19154
|
+
}
|
|
19155
|
+
return { handle, owner };
|
|
19156
|
+
} catch (cause) {
|
|
19157
|
+
if (!(cause instanceof Error) || !("code" in cause) || cause.code !== "EEXIST") {
|
|
19158
|
+
throw cause;
|
|
19159
|
+
}
|
|
19160
|
+
try {
|
|
19161
|
+
const lock = await stat(path2);
|
|
19162
|
+
if (Date.now() - lock.mtimeMs > refreshLockTimeoutMs) {
|
|
19163
|
+
throw new Error(`The CLI credential lock appears stale at ${path2}. Ensure no CLI authentication command is running, remove that lock file, and retry.`);
|
|
19164
|
+
}
|
|
19165
|
+
} catch (statCause) {
|
|
19166
|
+
if (statCause instanceof Error && "code" in statCause && statCause.code === "ENOENT") {
|
|
19167
|
+
continue;
|
|
19168
|
+
}
|
|
19169
|
+
throw statCause;
|
|
19170
|
+
}
|
|
19171
|
+
await Bun.sleep(refreshLockRetryMs);
|
|
19172
|
+
}
|
|
19173
|
+
}
|
|
19174
|
+
throw new Error("Timed out waiting for another CLI process to refresh credentials. Retry the command.");
|
|
19175
|
+
};
|
|
19089
19176
|
var createBunCredentialStore = ({
|
|
19090
19177
|
account = "default",
|
|
19091
19178
|
product,
|
|
@@ -19113,16 +19200,46 @@ var createBunCredentialStore = ({
|
|
|
19113
19200
|
service,
|
|
19114
19201
|
value: JSON.stringify(parsed)
|
|
19115
19202
|
}));
|
|
19203
|
+
},
|
|
19204
|
+
withCredentialLock: async (operation) => {
|
|
19205
|
+
const refreshLockPath = await getCredentialLockPath(product, account);
|
|
19206
|
+
const lock = await acquireRefreshLock(refreshLockPath);
|
|
19207
|
+
try {
|
|
19208
|
+
return await operation();
|
|
19209
|
+
} finally {
|
|
19210
|
+
await lock.handle.close();
|
|
19211
|
+
const owner = await readFile(refreshLockPath, "utf8").catch(() => null);
|
|
19212
|
+
if (owner === lock.owner) {
|
|
19213
|
+
await unlink(refreshLockPath).catch(() => {
|
|
19214
|
+
return;
|
|
19215
|
+
});
|
|
19216
|
+
}
|
|
19217
|
+
}
|
|
19116
19218
|
}
|
|
19117
19219
|
};
|
|
19118
19220
|
};
|
|
19119
19221
|
// ../../cli-auth/src/client/login.ts
|
|
19120
19222
|
import { hostname as hostname3 } from "os";
|
|
19121
|
-
var
|
|
19122
|
-
|
|
19223
|
+
var normalizeCliAuthIssuer = (value) => {
|
|
19224
|
+
const url2 = new URL(value);
|
|
19225
|
+
url2.hash = "";
|
|
19226
|
+
url2.search = "";
|
|
19227
|
+
url2.pathname = url2.pathname.replace(/\/+$/, "");
|
|
19228
|
+
return url2.toString().replace(/\/+$/, "");
|
|
19229
|
+
};
|
|
19230
|
+
var isCliCredentialForIssuer = (credentials, apiBaseUrl) => credentials.issuer === normalizeCliAuthIssuer(apiBaseUrl);
|
|
19231
|
+
var credentialIssuerError = (issuer) => issuer ? new Error("These CLI credentials belong to a different API URL. Run auth login for this API URL before retrying.") : new Error("These CLI credentials were created by an older CLI and are not bound to an API URL. Run auth login again.");
|
|
19232
|
+
var assertCredentialIssuer = (credentials, apiBaseUrl) => {
|
|
19233
|
+
const expectedIssuer = normalizeCliAuthIssuer(apiBaseUrl);
|
|
19234
|
+
if (!credentials.issuer || credentials.issuer !== expectedIssuer) {
|
|
19235
|
+
throw credentialIssuerError(credentials.issuer);
|
|
19236
|
+
}
|
|
19237
|
+
};
|
|
19238
|
+
var postJson = async (fetchImplementation, url2, body, options = {}) => fetchImplementation(url2, {
|
|
19123
19239
|
body: JSON.stringify(body),
|
|
19124
19240
|
headers: { "content-type": "application/json" },
|
|
19125
|
-
method: "POST"
|
|
19241
|
+
method: "POST",
|
|
19242
|
+
...options
|
|
19126
19243
|
});
|
|
19127
19244
|
var readJson = async (response) => {
|
|
19128
19245
|
try {
|
|
@@ -19131,9 +19248,29 @@ var readJson = async (response) => {
|
|
|
19131
19248
|
throw new Error(`CLI authentication returned HTTP ${response.status} without valid JSON.`);
|
|
19132
19249
|
}
|
|
19133
19250
|
};
|
|
19134
|
-
var
|
|
19251
|
+
var localCredentialLocks = new WeakMap;
|
|
19252
|
+
var withCredentialLock = (store, operation) => {
|
|
19253
|
+
if (store.withCredentialLock) {
|
|
19254
|
+
return store.withCredentialLock(operation);
|
|
19255
|
+
}
|
|
19256
|
+
const previous = localCredentialLocks.get(store) ?? Promise.resolve();
|
|
19257
|
+
const pending = previous.then(operation, operation);
|
|
19258
|
+
const tail = pending.then(() => {
|
|
19259
|
+
return;
|
|
19260
|
+
}, () => {
|
|
19261
|
+
return;
|
|
19262
|
+
});
|
|
19263
|
+
localCredentialLocks.set(store, tail);
|
|
19264
|
+
return pending.finally(() => {
|
|
19265
|
+
if (localCredentialLocks.get(store) === tail) {
|
|
19266
|
+
localCredentialLocks.delete(store);
|
|
19267
|
+
}
|
|
19268
|
+
});
|
|
19269
|
+
};
|
|
19270
|
+
var toStoredCredentials = (response, issuer, now) => ({
|
|
19135
19271
|
accessToken: response.accessToken,
|
|
19136
19272
|
accessTokenExpiresAt: new Date(now.getTime() + response.expiresIn * 1000).toISOString(),
|
|
19273
|
+
issuer,
|
|
19137
19274
|
refreshToken: response.refreshToken,
|
|
19138
19275
|
refreshTokenExpiresAt: new Date(now.getTime() + response.refreshExpiresIn * 1000).toISOString(),
|
|
19139
19276
|
session: response.session
|
|
@@ -19167,7 +19304,7 @@ var loginWithBrowser = async ({
|
|
|
19167
19304
|
sleep = (milliseconds) => Bun.sleep(milliseconds),
|
|
19168
19305
|
store
|
|
19169
19306
|
}) => {
|
|
19170
|
-
const baseUrl =
|
|
19307
|
+
const baseUrl = normalizeCliAuthIssuer(apiBaseUrl);
|
|
19171
19308
|
const pkce = createPkcePair();
|
|
19172
19309
|
const beginResponse = await postJson(fetchImplementation, `${baseUrl}/api/cli-auth/device`, {
|
|
19173
19310
|
clientName,
|
|
@@ -19194,8 +19331,8 @@ var loginWithBrowser = async ({
|
|
|
19194
19331
|
const payload = await readJson(response);
|
|
19195
19332
|
if (response.ok) {
|
|
19196
19333
|
const issued = tokenResponseSchema.parse(payload);
|
|
19197
|
-
const credentials = toStoredCredentials(issued, now());
|
|
19198
|
-
await store.set(credentials);
|
|
19334
|
+
const credentials = toStoredCredentials(issued, baseUrl, now());
|
|
19335
|
+
await withCredentialLock(store, () => store.set(credentials));
|
|
19199
19336
|
return credentials;
|
|
19200
19337
|
}
|
|
19201
19338
|
const error61 = deviceTokenErrorSchema.safeParse(payload);
|
|
@@ -19216,6 +19353,26 @@ var loginWithBrowser = async ({
|
|
|
19216
19353
|
throw new Error("CLI authentication expired. Run auth login again.");
|
|
19217
19354
|
};
|
|
19218
19355
|
var refreshes = new WeakMap;
|
|
19356
|
+
var refreshRequestTimeoutMs = 30000;
|
|
19357
|
+
var isInvalidGrantResponse = (payload) => {
|
|
19358
|
+
const parsed = deviceTokenErrorSchema.safeParse(payload);
|
|
19359
|
+
return parsed.success && parsed.data.error === "invalid_grant";
|
|
19360
|
+
};
|
|
19361
|
+
var readJsonOrNull = async (response) => {
|
|
19362
|
+
try {
|
|
19363
|
+
return await response.json();
|
|
19364
|
+
} catch {
|
|
19365
|
+
return null;
|
|
19366
|
+
}
|
|
19367
|
+
};
|
|
19368
|
+
var deleteInvalidCredentials = async (store, credentials) => {
|
|
19369
|
+
const current = await store.get();
|
|
19370
|
+
if (!current)
|
|
19371
|
+
return;
|
|
19372
|
+
if (current.issuer === credentials.issuer && current.refreshToken === credentials.refreshToken) {
|
|
19373
|
+
await store.delete();
|
|
19374
|
+
}
|
|
19375
|
+
};
|
|
19219
19376
|
var refreshAccessToken = async ({
|
|
19220
19377
|
apiBaseUrl,
|
|
19221
19378
|
credentials,
|
|
@@ -19223,16 +19380,56 @@ var refreshAccessToken = async ({
|
|
|
19223
19380
|
now,
|
|
19224
19381
|
store
|
|
19225
19382
|
}) => {
|
|
19226
|
-
|
|
19383
|
+
assertCredentialIssuer(credentials, apiBaseUrl);
|
|
19384
|
+
let response;
|
|
19385
|
+
try {
|
|
19386
|
+
response = await postJson(fetchImplementation, `${normalizeCliAuthIssuer(apiBaseUrl)}/api/cli-auth/refresh`, { refreshToken: credentials.refreshToken }, { signal: AbortSignal.timeout(refreshRequestTimeoutMs) });
|
|
19387
|
+
} catch {
|
|
19388
|
+
throw new Error("Unable to refresh the CLI session because the API could not be reached. Credentials were kept; retry the command.");
|
|
19389
|
+
}
|
|
19227
19390
|
if (!response.ok) {
|
|
19228
|
-
await
|
|
19229
|
-
|
|
19391
|
+
const payload2 = await readJsonOrNull(response);
|
|
19392
|
+
if (isInvalidGrantResponse(payload2)) {
|
|
19393
|
+
await deleteInvalidCredentials(store, credentials);
|
|
19394
|
+
throw new Error("The CLI session expired or was revoked. Run auth login again.");
|
|
19395
|
+
}
|
|
19396
|
+
throw new Error(`Unable to refresh the CLI session (HTTP ${response.status}). Credentials were kept; retry the command.`);
|
|
19230
19397
|
}
|
|
19231
|
-
const
|
|
19232
|
-
|
|
19398
|
+
const payload = await readJsonOrNull(response);
|
|
19399
|
+
if (payload === null) {
|
|
19400
|
+
throw new Error("Unable to refresh the CLI session because the API returned malformed JSON. Credentials were kept; retry the command.");
|
|
19401
|
+
}
|
|
19402
|
+
let issued;
|
|
19403
|
+
try {
|
|
19404
|
+
issued = tokenResponseSchema.parse(payload);
|
|
19405
|
+
} catch {
|
|
19406
|
+
throw new Error("Unable to refresh the CLI session because the API returned invalid credentials. Credentials were kept; retry the command.");
|
|
19407
|
+
}
|
|
19408
|
+
const stored = toStoredCredentials(issued, normalizeCliAuthIssuer(apiBaseUrl), now());
|
|
19233
19409
|
await store.set(stored);
|
|
19234
19410
|
return stored.accessToken;
|
|
19235
19411
|
};
|
|
19412
|
+
var refreshWithCurrentCredentials = async ({
|
|
19413
|
+
apiBaseUrl,
|
|
19414
|
+
fetch: fetchImplementation,
|
|
19415
|
+
now,
|
|
19416
|
+
store
|
|
19417
|
+
}) => {
|
|
19418
|
+
const credentials = await store.get();
|
|
19419
|
+
if (!credentials)
|
|
19420
|
+
throw new Error("Not authenticated. Run auth login first.");
|
|
19421
|
+
assertCredentialIssuer(credentials, apiBaseUrl);
|
|
19422
|
+
if (Date.parse(credentials.accessTokenExpiresAt) - now().getTime() > 60000) {
|
|
19423
|
+
return credentials.accessToken;
|
|
19424
|
+
}
|
|
19425
|
+
return refreshAccessToken({
|
|
19426
|
+
apiBaseUrl,
|
|
19427
|
+
credentials,
|
|
19428
|
+
fetch: fetchImplementation,
|
|
19429
|
+
now,
|
|
19430
|
+
store
|
|
19431
|
+
});
|
|
19432
|
+
};
|
|
19236
19433
|
var getAuthenticatedAccessToken = async ({
|
|
19237
19434
|
apiBaseUrl,
|
|
19238
19435
|
fetch: fetchImplementation = globalThis.fetch,
|
|
@@ -19242,19 +19439,20 @@ var getAuthenticatedAccessToken = async ({
|
|
|
19242
19439
|
const credentials = await store.get();
|
|
19243
19440
|
if (!credentials)
|
|
19244
19441
|
throw new Error("Not authenticated. Run auth login first.");
|
|
19442
|
+
assertCredentialIssuer(credentials, apiBaseUrl);
|
|
19245
19443
|
if (Date.parse(credentials.accessTokenExpiresAt) - now().getTime() > 60000) {
|
|
19246
19444
|
return credentials.accessToken;
|
|
19247
19445
|
}
|
|
19248
19446
|
const existing = refreshes.get(store);
|
|
19249
19447
|
if (existing)
|
|
19250
19448
|
return existing;
|
|
19251
|
-
const
|
|
19449
|
+
const refreshOperation = () => withCredentialLock(store, () => refreshWithCurrentCredentials({
|
|
19252
19450
|
apiBaseUrl,
|
|
19253
|
-
credentials,
|
|
19254
19451
|
fetch: fetchImplementation,
|
|
19255
19452
|
now,
|
|
19256
19453
|
store
|
|
19257
|
-
})
|
|
19454
|
+
}));
|
|
19455
|
+
const refresh = refreshOperation().finally(() => refreshes.delete(store));
|
|
19258
19456
|
refreshes.set(store, refresh);
|
|
19259
19457
|
return refresh;
|
|
19260
19458
|
};
|
|
@@ -19263,18 +19461,22 @@ var logoutCliSession = async ({
|
|
|
19263
19461
|
fetch: fetchImplementation = globalThis.fetch,
|
|
19264
19462
|
store
|
|
19265
19463
|
}) => {
|
|
19266
|
-
const
|
|
19267
|
-
|
|
19268
|
-
|
|
19269
|
-
|
|
19270
|
-
|
|
19271
|
-
|
|
19272
|
-
|
|
19273
|
-
|
|
19464
|
+
const issuer = normalizeCliAuthIssuer(apiBaseUrl);
|
|
19465
|
+
return withCredentialLock(store, async () => {
|
|
19466
|
+
const credentials = await store.get();
|
|
19467
|
+
let remoteRevoked = credentials === null;
|
|
19468
|
+
if (credentials) {
|
|
19469
|
+
try {
|
|
19470
|
+
assertCredentialIssuer(credentials, apiBaseUrl);
|
|
19471
|
+
const response = await postJson(fetchImplementation, `${issuer}/api/cli-auth/revoke`, { refreshToken: credentials.refreshToken }, { signal: AbortSignal.timeout(refreshRequestTimeoutMs) });
|
|
19472
|
+
remoteRevoked = response.ok;
|
|
19473
|
+
} catch {
|
|
19474
|
+
remoteRevoked = false;
|
|
19475
|
+
}
|
|
19274
19476
|
}
|
|
19275
|
-
|
|
19276
|
-
|
|
19277
|
-
|
|
19477
|
+
await store.delete();
|
|
19478
|
+
return { remoteRevoked };
|
|
19479
|
+
});
|
|
19278
19480
|
};
|
|
19279
19481
|
// src/api-client.ts
|
|
19280
19482
|
import {
|
|
@@ -19866,6 +20068,10 @@ var typedOperations = {
|
|
|
19866
20068
|
kind: "query",
|
|
19867
20069
|
invoke: (client, input2) => client.team.list.query(input2)
|
|
19868
20070
|
},
|
|
20071
|
+
"team.getStationRoutes": {
|
|
20072
|
+
kind: "query",
|
|
20073
|
+
invoke: (client, input2) => client.team.getStationRoutes.query(input2)
|
|
20074
|
+
},
|
|
19869
20075
|
"team.create": {
|
|
19870
20076
|
kind: "mutation",
|
|
19871
20077
|
invoke: (client, input2) => client.team.create.mutate(input2)
|
|
@@ -19890,6 +20096,10 @@ var typedOperations = {
|
|
|
19890
20096
|
kind: "mutation",
|
|
19891
20097
|
invoke: (client, input2) => client.team.setSubmissionAccess.mutate(input2)
|
|
19892
20098
|
},
|
|
20099
|
+
"team.setStationRoutes": {
|
|
20100
|
+
kind: "mutation",
|
|
20101
|
+
invoke: (client, input2) => client.team.setStationRoutes.mutate(input2)
|
|
20102
|
+
},
|
|
19893
20103
|
"template.list": {
|
|
19894
20104
|
kind: "query",
|
|
19895
20105
|
invoke: (client, input2) => client.template.list.query(input2)
|
|
@@ -20363,7 +20573,7 @@ var commands = [
|
|
|
20363
20573
|
]),
|
|
20364
20574
|
mutation(["station", "clone"], "event.cloneStation", "Clone a station and its missions.", ["state-change"]),
|
|
20365
20575
|
mutation(["station", "archive"], "event.archiveStation", "Archive a station.", ["state-change", "delete"]),
|
|
20366
|
-
mutation(["station", "reorder"], "event.reorderStations", "Reorder
|
|
20576
|
+
mutation(["station", "reorder"], "event.reorderStations", "Reorder shared stations, or a selected team's station route when scopeTeamId is supplied.", ["state-change"]),
|
|
20367
20577
|
mutation(["mission", "create"], "event.createMission", "Create a mission in a station.", ["state-change"]),
|
|
20368
20578
|
mutation(["mission", "update"], "event.updateMission", "Update a mission.", [
|
|
20369
20579
|
"state-change"
|
|
@@ -20371,6 +20581,7 @@ var commands = [
|
|
|
20371
20581
|
mutation(["mission", "archive"], "event.archiveMission", "Archive a mission.", ["state-change", "delete"]),
|
|
20372
20582
|
mutation(["mission", "reorder"], "event.reorderMissions", "Reorder missions within a station.", ["state-change"]),
|
|
20373
20583
|
query(["team", "list"], "team.list", "List teams and their current members."),
|
|
20584
|
+
query(["team", "station-routes"], "team.getStationRoutes", "List each team's linked stations and team-specific route order."),
|
|
20374
20585
|
mutation(["team", "create"], "team.create", "Create a team.", [
|
|
20375
20586
|
"state-change"
|
|
20376
20587
|
]),
|
|
@@ -20381,10 +20592,11 @@ var commands = [
|
|
|
20381
20592
|
mutation(["team", "remove-member"], "team.removeMember", "Remove a member from a team.", ["state-change", "delete"]),
|
|
20382
20593
|
mutation(["team", "set-play-mode"], "team.setPlayMode", "Change an event between individual and team play.", ["state-change"]),
|
|
20383
20594
|
mutation(["team", "set-submission-access"], "team.setSubmissionAccess", "Change who can submit missions in team play.", ["state-change"]),
|
|
20384
|
-
|
|
20385
|
-
query(["template", "
|
|
20386
|
-
|
|
20387
|
-
mutation(["template", "
|
|
20595
|
+
mutation(["team", "set-station-routes"], "team.setStationRoutes", "Replace a team's linked station route and set its order.", ["state-change"]),
|
|
20596
|
+
query(["template", "list"], "template.list", "List templates visible in an organization; global admins can include every organization."),
|
|
20597
|
+
query(["template", "get"], "template.getById", "Get a template visible in the requested organization; global admins can get any template."),
|
|
20598
|
+
mutation(["template", "create"], "template.create", "Create an organization template, including shared stations with optional per-team route order.", ["state-change"]),
|
|
20599
|
+
mutation(["template", "update"], "template.update", "Replace a template's setup, including shared stations with optional per-team route order.", ["state-change"]),
|
|
20388
20600
|
mutation(["template", "update-metadata"], "template.updateMetadata", "Update template metadata.", ["state-change"]),
|
|
20389
20601
|
mutation(["template", "delete"], "template.delete", "Delete a template.", [
|
|
20390
20602
|
"state-change",
|
|
@@ -20491,7 +20703,7 @@ var findCommand = (positional) => {
|
|
|
20491
20703
|
return;
|
|
20492
20704
|
};
|
|
20493
20705
|
// src/features/upload.ts
|
|
20494
|
-
import { open as
|
|
20706
|
+
import { open as open3, readFile as readFile2, stat as stat2 } from "fs/promises";
|
|
20495
20707
|
import { basename, extname } from "path";
|
|
20496
20708
|
var contentTypes = {
|
|
20497
20709
|
".gif": "image/gif",
|
|
@@ -20561,7 +20773,7 @@ var uploadMultipartMissionMedia = async ({
|
|
|
20561
20773
|
input: input2,
|
|
20562
20774
|
metadata
|
|
20563
20775
|
}) => {
|
|
20564
|
-
const file2 = await
|
|
20776
|
+
const file2 = await open3(filePath, "r");
|
|
20565
20777
|
const completedParts = [];
|
|
20566
20778
|
try {
|
|
20567
20779
|
for (const part of metadata.parts) {
|
|
@@ -20607,7 +20819,7 @@ var uploadFileForCommand = async ({
|
|
|
20607
20819
|
if (!isRecord2(input2)) {
|
|
20608
20820
|
throw new CliError("VALIDATION", "Upload command input must be an object.");
|
|
20609
20821
|
}
|
|
20610
|
-
const file2 = await
|
|
20822
|
+
const file2 = await stat2(filePath).catch(() => null);
|
|
20611
20823
|
if (!file2?.isFile()) {
|
|
20612
20824
|
throw new CliError("VALIDATION", `File not found: ${filePath}`);
|
|
20613
20825
|
}
|
|
@@ -20634,7 +20846,7 @@ var uploadFileForCommand = async ({
|
|
|
20634
20846
|
return { completed, key: multipart.key, strategy: multipart.strategy };
|
|
20635
20847
|
}
|
|
20636
20848
|
const single = asSingleResult(requested);
|
|
20637
|
-
const singleFile = await
|
|
20849
|
+
const singleFile = await readFile2(filePath);
|
|
20638
20850
|
await putPart(single.uploadUrl, singleFile.buffer.slice(singleFile.byteOffset, singleFile.byteOffset + singleFile.byteLength), contentType);
|
|
20639
20851
|
return {
|
|
20640
20852
|
...single.imageUrl ? { imageUrl: single.imageUrl } : {},
|
|
@@ -20842,7 +21054,7 @@ Waiting for approval\u2026
|
|
|
20842
21054
|
}
|
|
20843
21055
|
if (first === "auth" && rest[0] === "status" && rest.length === 1) {
|
|
20844
21056
|
const credentials = await credentialStore.get();
|
|
20845
|
-
return emitSuccess(dependencies.runtime, { apiBaseUrl, cliVersion: GOSH_CLI_VERSION, command: "auth status" }, credentials ? {
|
|
21057
|
+
return emitSuccess(dependencies.runtime, { apiBaseUrl, cliVersion: GOSH_CLI_VERSION, command: "auth status" }, credentials && isCliCredentialForIssuer(credentials, apiBaseUrl) ? {
|
|
20846
21058
|
authenticated: true,
|
|
20847
21059
|
accessTokenExpiresAt: credentials.accessTokenExpiresAt,
|
|
20848
21060
|
refreshTokenExpiresAt: credentials.refreshTokenExpiresAt,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@go-labs-sg/gosh",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "GOSH CLI for organizers and AI agents — manage organizations, events, teams, stations, missions, templates, and live operations through the GOSH API.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|