crewly 1.5.22 → 1.6.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/config/roles/orchestrator/prompt.md +182 -25
- package/config/skills/agent/core/cancel-followup/SKILL.md +38 -0
- package/config/skills/agent/core/cancel-followup/execute.sh +111 -0
- package/config/skills/agent/core/cancel-followup/execute.test.sh +42 -0
- package/config/skills/agent/core/list-my-followups/SKILL.md +36 -0
- package/config/skills/agent/core/list-my-followups/execute.sh +93 -0
- package/config/skills/agent/core/list-my-followups/execute.test.sh +41 -0
- package/config/skills/agent/core/schedule-followup/SKILL.md +53 -0
- package/config/skills/agent/core/schedule-followup/execute.sh +195 -0
- package/config/skills/agent/core/schedule-followup/execute.test.sh +48 -0
- package/config/skills/agent/core/watch-for-event/SKILL.md +60 -0
- package/config/skills/agent/core/watch-for-event/execute.sh +177 -0
- package/config/skills/agent/core/watch-for-event/execute.test.sh +43 -0
- package/config/skills/orchestrator/credential-manager/SKILL.md +218 -0
- package/config/skills/orchestrator/credential-manager/execute.sh +166 -0
- package/dist/backend/backend/src/controllers/credentials/credentials.controller.d.ts +80 -0
- package/dist/backend/backend/src/controllers/credentials/credentials.controller.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/credentials/credentials.controller.js +365 -0
- package/dist/backend/backend/src/controllers/credentials/credentials.controller.js.map +1 -0
- package/dist/backend/backend/src/controllers/credentials/credentials.routes.d.ts +26 -0
- package/dist/backend/backend/src/controllers/credentials/credentials.routes.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/credentials/credentials.routes.js +40 -0
- package/dist/backend/backend/src/controllers/credentials/credentials.routes.js.map +1 -0
- package/dist/backend/backend/src/controllers/task-pool/task-pool.controller.js +23 -14
- package/dist/backend/backend/src/controllers/task-pool/task-pool.controller.js.map +1 -1
- package/dist/backend/backend/src/scripts/backfill-mission-priority.d.ts +3 -1
- package/dist/backend/backend/src/scripts/backfill-mission-priority.d.ts.map +1 -1
- package/dist/backend/backend/src/scripts/backfill-mission-priority.js +16 -4
- package/dist/backend/backend/src/scripts/backfill-mission-priority.js.map +1 -1
- package/dist/backend/backend/src/services/browser/browser-proxy.service.js +1 -1
- package/dist/backend/backend/src/services/browser/browser-proxy.service.js.map +1 -1
- package/dist/backend/backend/src/services/credential/credential-store.service.d.ts +161 -0
- package/dist/backend/backend/src/services/credential/credential-store.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/credential/credential-store.service.js +298 -0
- package/dist/backend/backend/src/services/credential/credential-store.service.js.map +1 -0
- package/dist/backend/backend/src/services/credential/helpers/gemini-cli-workspace.helper.d.ts +117 -0
- package/dist/backend/backend/src/services/credential/helpers/gemini-cli-workspace.helper.d.ts.map +1 -0
- package/dist/backend/backend/src/services/credential/helpers/gemini-cli-workspace.helper.js +293 -0
- package/dist/backend/backend/src/services/credential/helpers/gemini-cli-workspace.helper.js.map +1 -0
- package/dist/backend/backend/src/services/project/task.service.d.ts +18 -2
- package/dist/backend/backend/src/services/project/task.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/project/task.service.js +69 -53
- package/dist/backend/backend/src/services/project/task.service.js.map +1 -1
- package/dist/backend/backend/src/services/v3/contract-matcher.d.ts +20 -0
- package/dist/backend/backend/src/services/v3/contract-matcher.d.ts.map +1 -0
- package/dist/backend/backend/src/services/v3/contract-matcher.js +33 -0
- package/dist/backend/backend/src/services/v3/contract-matcher.js.map +1 -0
- package/dist/backend/backend/src/services/v3/escalation.service.d.ts +20 -1
- package/dist/backend/backend/src/services/v3/escalation.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/escalation.service.js +97 -28
- package/dist/backend/backend/src/services/v3/escalation.service.js.map +1 -1
- package/dist/backend/backend/src/services/v3/service-contract-gate.service.d.ts +6 -4
- package/dist/backend/backend/src/services/v3/service-contract-gate.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/service-contract-gate.service.js +18 -28
- package/dist/backend/backend/src/services/v3/service-contract-gate.service.js.map +1 -1
- package/dist/backend/backend/src/services/v3/team-trigger-reconciler.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/team-trigger-reconciler.service.js +14 -9
- package/dist/backend/backend/src/services/v3/team-trigger-reconciler.service.js.map +1 -1
- package/dist/backend/backend/src/services/v3/trigger-engine.service.d.ts +34 -1
- package/dist/backend/backend/src/services/v3/trigger-engine.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/trigger-engine.service.js +115 -5
- package/dist/backend/backend/src/services/v3/trigger-engine.service.js.map +1 -1
- package/dist/backend/backend/src/types/credential.types.d.ts +185 -0
- package/dist/backend/backend/src/types/credential.types.d.ts.map +1 -0
- package/dist/backend/backend/src/types/credential.types.js +76 -0
- package/dist/backend/backend/src/types/credential.types.js.map +1 -0
- package/dist/backend/backend/src/utils/encryption.utils.d.ts +57 -0
- package/dist/backend/backend/src/utils/encryption.utils.d.ts.map +1 -0
- package/dist/backend/backend/src/utils/encryption.utils.js +162 -0
- package/dist/backend/backend/src/utils/encryption.utils.js.map +1 -0
- package/dist/cli/backend/src/services/credential/credential-store.service.d.ts +161 -0
- package/dist/cli/backend/src/services/credential/credential-store.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/credential/credential-store.service.js +298 -0
- package/dist/cli/backend/src/services/credential/credential-store.service.js.map +1 -0
- package/dist/cli/backend/src/services/credential/helpers/gemini-cli-workspace.helper.d.ts +117 -0
- package/dist/cli/backend/src/services/credential/helpers/gemini-cli-workspace.helper.d.ts.map +1 -0
- package/dist/cli/backend/src/services/credential/helpers/gemini-cli-workspace.helper.js +293 -0
- package/dist/cli/backend/src/services/credential/helpers/gemini-cli-workspace.helper.js.map +1 -0
- package/dist/cli/backend/src/services/settings/settings.service.d.ts +168 -0
- package/dist/cli/backend/src/services/settings/settings.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/settings/settings.service.js +312 -0
- package/dist/cli/backend/src/services/settings/settings.service.js.map +1 -0
- package/dist/cli/backend/src/services/skill/skill-executor.service.d.ts +159 -0
- package/dist/cli/backend/src/services/skill/skill-executor.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/skill/skill-executor.service.js +626 -0
- package/dist/cli/backend/src/services/skill/skill-executor.service.js.map +1 -0
- package/dist/cli/backend/src/services/skill/skill.service.d.ts +273 -0
- package/dist/cli/backend/src/services/skill/skill.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/skill/skill.service.js +655 -0
- package/dist/cli/backend/src/services/skill/skill.service.js.map +1 -0
- package/dist/cli/backend/src/types/credential.types.d.ts +185 -0
- package/dist/cli/backend/src/types/credential.types.d.ts.map +1 -0
- package/dist/cli/backend/src/types/credential.types.js +76 -0
- package/dist/cli/backend/src/types/credential.types.js.map +1 -0
- package/dist/cli/backend/src/utils/encryption.utils.d.ts +57 -0
- package/dist/cli/backend/src/utils/encryption.utils.d.ts.map +1 -0
- package/dist/cli/backend/src/utils/encryption.utils.js +162 -0
- package/dist/cli/backend/src/utils/encryption.utils.js.map +1 -0
- package/dist/cli/backend/src/utils/skill-md-parser.d.ts +38 -0
- package/dist/cli/backend/src/utils/skill-md-parser.d.ts.map +1 -0
- package/dist/cli/backend/src/utils/skill-md-parser.js +47 -0
- package/dist/cli/backend/src/utils/skill-md-parser.js.map +1 -0
- package/frontend/dist/assets/{index-dc92ab64.css → index-6aaa0630.css} +1 -1
- package/frontend/dist/assets/{index-76d76633.js → index-9e6d97d1.js} +334 -328
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
- package/config/experts/empathetic-resolver/expert.json +0 -11
- package/config/experts/empathetic-resolver.md +0 -32
- package/config/experts/pragmatic-architect/expert.json +0 -11
- package/config/experts/pragmatic-architect.md +0 -32
- package/config/experts/viral-alchemist/expert.json +0 -11
- package/config/experts/viral-alchemist.md +0 -32
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini CLI Workspace Credential Helper
|
|
3
|
+
*
|
|
4
|
+
* Piggybacks on the Google Workspace extension for Gemini CLI
|
|
5
|
+
* (https://github.com/gemini-cli-extensions/workspace) for Google OAuth
|
|
6
|
+
* credential acquisition and refresh.
|
|
7
|
+
*
|
|
8
|
+
* **Capture** reads the extension's file-storage token file (written when
|
|
9
|
+
* the user runs the extension with `GEMINI_CLI_WORKSPACE_FORCE_FILE_STORAGE=true`)
|
|
10
|
+
* and imports the tokens into Crewly's own encrypted store.
|
|
11
|
+
*
|
|
12
|
+
* **Refresh** POSTs the stored `refresh_token` to the extension's Cloud
|
|
13
|
+
* Function `/refreshToken` endpoint — no client_secret needed on our side.
|
|
14
|
+
*
|
|
15
|
+
* After capture, Crewly owns the tokens; the extension's own state is not
|
|
16
|
+
* depended on for day-to-day operation.
|
|
17
|
+
*
|
|
18
|
+
* @module services/credential/helpers/gemini-cli-workspace.helper
|
|
19
|
+
*/
|
|
20
|
+
import { CredentialHelper, CredentialHelperName, CredentialRegistryEntry, GoogleOAuthPayload } from '../../../types/credential.types.js';
|
|
21
|
+
import { CredentialStoreService } from '../credential-store.service.js';
|
|
22
|
+
/**
|
|
23
|
+
* Minimal fetch-compatible function shape (for test injection).
|
|
24
|
+
*/
|
|
25
|
+
export type FetchLike = (url: string, init?: {
|
|
26
|
+
method?: string;
|
|
27
|
+
headers?: Record<string, string>;
|
|
28
|
+
body?: string;
|
|
29
|
+
}) => Promise<{
|
|
30
|
+
ok: boolean;
|
|
31
|
+
status: number;
|
|
32
|
+
text(): Promise<string>;
|
|
33
|
+
json(): Promise<unknown>;
|
|
34
|
+
}>;
|
|
35
|
+
/**
|
|
36
|
+
* Helper configuration — all fields optional; defaults match production.
|
|
37
|
+
*/
|
|
38
|
+
export interface GeminiCliHelperConfig {
|
|
39
|
+
/** Path to the extension install directory (default: `~/.gemini/extensions/google-workspace`). */
|
|
40
|
+
extensionPath?: string;
|
|
41
|
+
/** Cloud Function base URL. */
|
|
42
|
+
cloudFunctionUrl?: string;
|
|
43
|
+
/** Path on the cloud function for refresh calls. */
|
|
44
|
+
refreshPath?: string;
|
|
45
|
+
/** Client ID to store on the credential (for later refresh identification). */
|
|
46
|
+
clientId?: string;
|
|
47
|
+
/** Refresh buffer in ms — refresh if token expires within this window. */
|
|
48
|
+
expiryBufferMs?: number;
|
|
49
|
+
/** Fetch implementation (injectable for tests). */
|
|
50
|
+
fetch?: FetchLike;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Credential helper that reads tokens from the gemini-cli-workspace extension
|
|
54
|
+
* and refreshes them via the extension's Cloud Function.
|
|
55
|
+
*/
|
|
56
|
+
export declare class GeminiCliWorkspaceHelper implements CredentialHelper {
|
|
57
|
+
readonly name: CredentialHelperName;
|
|
58
|
+
private readonly extensionPath;
|
|
59
|
+
private readonly cloudFunctionUrl;
|
|
60
|
+
private readonly refreshPath;
|
|
61
|
+
private readonly clientId;
|
|
62
|
+
private readonly expiryBufferMs;
|
|
63
|
+
private readonly fetchFn;
|
|
64
|
+
private readonly store;
|
|
65
|
+
/** Per-credential refresh in-flight promises (serializes concurrent refreshes). */
|
|
66
|
+
private readonly refreshInFlight;
|
|
67
|
+
/** Per-credential last refresh attempt timestamp (for cooldown). */
|
|
68
|
+
private readonly lastRefreshAttempt;
|
|
69
|
+
constructor(store: CredentialStoreService, config?: GeminiCliHelperConfig);
|
|
70
|
+
/**
|
|
71
|
+
* Read the extension's current token file and return its contents as a
|
|
72
|
+
* `GoogleOAuthPayload` ready to persist in Crewly's store.
|
|
73
|
+
*
|
|
74
|
+
* The user must have completed extension login with
|
|
75
|
+
* `GEMINI_CLI_WORKSPACE_FORCE_FILE_STORAGE=true` before calling this.
|
|
76
|
+
*/
|
|
77
|
+
captureFromFile(): Promise<GoogleOAuthPayload>;
|
|
78
|
+
/**
|
|
79
|
+
* Remove the extension's token file. Call after `captureFromFile()` to
|
|
80
|
+
* prepare for the next account's login (the extension otherwise returns
|
|
81
|
+
* cached credentials if scopes match, skipping the login prompt).
|
|
82
|
+
* Leaves the master.key file untouched so existing ciphertexts remain
|
|
83
|
+
* decryptable if needed.
|
|
84
|
+
*/
|
|
85
|
+
clearExtensionFile(): Promise<void>;
|
|
86
|
+
/**
|
|
87
|
+
* Return a valid `GoogleOAuthPayload`, refreshing via the extension's
|
|
88
|
+
* cloud function if the access token is within the expiry buffer.
|
|
89
|
+
*
|
|
90
|
+
* Concurrent calls for the same credential share a single refresh.
|
|
91
|
+
*
|
|
92
|
+
* @throws CredentialRevokedError if the refresh token is no longer valid
|
|
93
|
+
*/
|
|
94
|
+
getAccessToken(entry: CredentialRegistryEntry, payload: GoogleOAuthPayload): Promise<GoogleOAuthPayload>;
|
|
95
|
+
private doRefresh;
|
|
96
|
+
/**
|
|
97
|
+
* Decrypt the extension's `{iv_hex}:{authTag_hex}:{ciphertext_hex}`
|
|
98
|
+
* format (AES-256-GCM).
|
|
99
|
+
*/
|
|
100
|
+
private decryptExtensionFormat;
|
|
101
|
+
/**
|
|
102
|
+
* Fetch the user's email via Google's userinfo endpoint. Best-effort;
|
|
103
|
+
* returns undefined on failure.
|
|
104
|
+
*/
|
|
105
|
+
private fetchUserEmail;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Encrypt a plaintext string with the extension's file format. Used in
|
|
109
|
+
* tests to generate fake token files.
|
|
110
|
+
*/
|
|
111
|
+
export declare function encryptExtensionFormatForTesting(plaintext: string, key: Buffer): string;
|
|
112
|
+
/**
|
|
113
|
+
* Derive the extension's encryption key using the real scrypt + salt.
|
|
114
|
+
* Test-only export.
|
|
115
|
+
*/
|
|
116
|
+
export declare function deriveExtensionKeyForTesting(masterKey: Buffer): Buffer;
|
|
117
|
+
//# sourceMappingURL=gemini-cli-workspace.helper.d.ts.map
|
package/dist/backend/backend/src/services/credential/helpers/gemini-cli-workspace.helper.d.ts.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gemini-cli-workspace.helper.d.ts","sourceRoot":"","sources":["../../../../../../../backend/src/services/credential/helpers/gemini-cli-workspace.helper.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAOH,OAAO,EACL,gBAAgB,EAChB,oBAAoB,EACpB,uBAAuB,EACvB,kBAAkB,EAEnB,MAAM,oCAAoC,CAAC;AAC5C,OAAO,EAAE,sBAAsB,EAAE,MAAM,gCAAgC,CAAC;AAmDxE;;GAEG;AACH,MAAM,MAAM,SAAS,GAAG,CACtB,GAAG,EAAE,MAAM,EACX,IAAI,CAAC,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,KACxE,OAAO,CAAC;IACX,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;IACxB,IAAI,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;CAC1B,CAAC,CAAC;AAEH;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC,kGAAkG;IAClG,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,+BAA+B;IAC/B,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,oDAAoD;IACpD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+EAA+E;IAC/E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,0EAA0E;IAC1E,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,mDAAmD;IACnD,KAAK,CAAC,EAAE,SAAS,CAAC;CACnB;AAMD;;;GAGG;AACH,qBAAa,wBAAyB,YAAW,gBAAgB;IAC/D,QAAQ,CAAC,IAAI,EAAE,oBAAoB,CAA0B;IAE7D,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAS;IAC1C,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAS;IACxC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAY;IACpC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAyB;IAE/C,mFAAmF;IACnF,OAAO,CAAC,QAAQ,CAAC,eAAe,CAG5B;IACJ,oEAAoE;IACpE,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAA6B;gBAG9D,KAAK,EAAE,sBAAsB,EAC7B,MAAM,CAAC,EAAE,qBAAqB;IAkBhC;;;;;;OAMG;IACG,eAAe,IAAI,OAAO,CAAC,kBAAkB,CAAC;IAoEpD;;;;;;OAMG;IACG,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC;IAczC;;;;;;;OAOG;IACG,cAAc,CAClB,KAAK,EAAE,uBAAuB,EAC9B,OAAO,EAAE,kBAAkB,GAC1B,OAAO,CAAC,kBAAkB,CAAC;YA4BhB,SAAS;IAuEvB;;;OAGG;IACH,OAAO,CAAC,sBAAsB;IAoB9B;;;OAGG;YACW,cAAc;CAe7B;AAMD;;;GAGG;AACH,wBAAgB,gCAAgC,CAC9C,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,MAAM,GACV,MAAM,CAOR;AAED;;;GAGG;AACH,wBAAgB,4BAA4B,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAGtE"}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini CLI Workspace Credential Helper
|
|
3
|
+
*
|
|
4
|
+
* Piggybacks on the Google Workspace extension for Gemini CLI
|
|
5
|
+
* (https://github.com/gemini-cli-extensions/workspace) for Google OAuth
|
|
6
|
+
* credential acquisition and refresh.
|
|
7
|
+
*
|
|
8
|
+
* **Capture** reads the extension's file-storage token file (written when
|
|
9
|
+
* the user runs the extension with `GEMINI_CLI_WORKSPACE_FORCE_FILE_STORAGE=true`)
|
|
10
|
+
* and imports the tokens into Crewly's own encrypted store.
|
|
11
|
+
*
|
|
12
|
+
* **Refresh** POSTs the stored `refresh_token` to the extension's Cloud
|
|
13
|
+
* Function `/refreshToken` endpoint — no client_secret needed on our side.
|
|
14
|
+
*
|
|
15
|
+
* After capture, Crewly owns the tokens; the extension's own state is not
|
|
16
|
+
* depended on for day-to-day operation.
|
|
17
|
+
*
|
|
18
|
+
* @module services/credential/helpers/gemini-cli-workspace.helper
|
|
19
|
+
*/
|
|
20
|
+
import { promises as fs } from 'fs';
|
|
21
|
+
import path from 'path';
|
|
22
|
+
import os from 'os';
|
|
23
|
+
import crypto from 'crypto';
|
|
24
|
+
import { CredentialRevokedError, } from '../../../types/credential.types.js';
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// Constants — extracted from the extension source
|
|
27
|
+
// (github.com/gemini-cli-extensions/workspace)
|
|
28
|
+
// ============================================================================
|
|
29
|
+
const DEFAULT_EXTENSION_PATH = path.join(os.homedir(), '.gemini', 'extensions', 'google-workspace');
|
|
30
|
+
const TOKEN_FILENAME = 'gemini-cli-workspace-token.json';
|
|
31
|
+
const MASTER_KEY_FILENAME = '.gemini-cli-workspace-master-key';
|
|
32
|
+
const MAIN_ACCOUNT_KEY = 'main-account';
|
|
33
|
+
const SALT_SUFFIX = '-gemini-cli-workspace';
|
|
34
|
+
const DEFAULT_CLOUD_FUNCTION_URL = 'https://google-workspace-extension.geminicli.com';
|
|
35
|
+
const DEFAULT_REFRESH_PATH = '/refreshToken';
|
|
36
|
+
const DEFAULT_CLIENT_ID = '338689075775-o75k922vn5fdl18qergr96rp8g63e4d7.apps.googleusercontent.com';
|
|
37
|
+
/** Refresh if the current access token expires within this window. */
|
|
38
|
+
const DEFAULT_EXPIRY_BUFFER_MS = 60_000;
|
|
39
|
+
/** Minimum gap between refresh attempts for the same credential. */
|
|
40
|
+
const REFRESH_COOLDOWN_MS = 30_000;
|
|
41
|
+
const USERINFO_ENDPOINT = 'https://www.googleapis.com/oauth2/v3/userinfo';
|
|
42
|
+
// ============================================================================
|
|
43
|
+
// Helper
|
|
44
|
+
// ============================================================================
|
|
45
|
+
/**
|
|
46
|
+
* Credential helper that reads tokens from the gemini-cli-workspace extension
|
|
47
|
+
* and refreshes them via the extension's Cloud Function.
|
|
48
|
+
*/
|
|
49
|
+
export class GeminiCliWorkspaceHelper {
|
|
50
|
+
name = 'gemini-cli-workspace';
|
|
51
|
+
extensionPath;
|
|
52
|
+
cloudFunctionUrl;
|
|
53
|
+
refreshPath;
|
|
54
|
+
clientId;
|
|
55
|
+
expiryBufferMs;
|
|
56
|
+
fetchFn;
|
|
57
|
+
store;
|
|
58
|
+
/** Per-credential refresh in-flight promises (serializes concurrent refreshes). */
|
|
59
|
+
refreshInFlight = new Map();
|
|
60
|
+
/** Per-credential last refresh attempt timestamp (for cooldown). */
|
|
61
|
+
lastRefreshAttempt = new Map();
|
|
62
|
+
constructor(store, config) {
|
|
63
|
+
this.store = store;
|
|
64
|
+
this.extensionPath = config?.extensionPath ?? DEFAULT_EXTENSION_PATH;
|
|
65
|
+
this.cloudFunctionUrl =
|
|
66
|
+
config?.cloudFunctionUrl ?? DEFAULT_CLOUD_FUNCTION_URL;
|
|
67
|
+
this.refreshPath = config?.refreshPath ?? DEFAULT_REFRESH_PATH;
|
|
68
|
+
this.clientId = config?.clientId ?? DEFAULT_CLIENT_ID;
|
|
69
|
+
this.expiryBufferMs = config?.expiryBufferMs ?? DEFAULT_EXPIRY_BUFFER_MS;
|
|
70
|
+
this.fetchFn =
|
|
71
|
+
config?.fetch ??
|
|
72
|
+
((url, init) => fetch(url, init));
|
|
73
|
+
}
|
|
74
|
+
// ------------------------------------------------------------------
|
|
75
|
+
// Capture
|
|
76
|
+
// ------------------------------------------------------------------
|
|
77
|
+
/**
|
|
78
|
+
* Read the extension's current token file and return its contents as a
|
|
79
|
+
* `GoogleOAuthPayload` ready to persist in Crewly's store.
|
|
80
|
+
*
|
|
81
|
+
* The user must have completed extension login with
|
|
82
|
+
* `GEMINI_CLI_WORKSPACE_FORCE_FILE_STORAGE=true` before calling this.
|
|
83
|
+
*/
|
|
84
|
+
async captureFromFile() {
|
|
85
|
+
const tokenPath = path.join(this.extensionPath, TOKEN_FILENAME);
|
|
86
|
+
const masterKeyPath = path.join(this.extensionPath, MASTER_KEY_FILENAME);
|
|
87
|
+
// Surface a user-friendly error if login hasn't happened yet
|
|
88
|
+
try {
|
|
89
|
+
await fs.access(tokenPath);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
throw new Error(`Gemini CLI Workspace token file not found at ${tokenPath}. ` +
|
|
93
|
+
`Please run 'GEMINI_CLI_WORKSPACE_FORCE_FILE_STORAGE=true gemini' ` +
|
|
94
|
+
`and complete Google sign-in first, then retry.`);
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
await fs.access(masterKeyPath);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
throw new Error(`Gemini CLI Workspace master key file not found at ${masterKeyPath}. ` +
|
|
101
|
+
`The extension should create this on first login.`);
|
|
102
|
+
}
|
|
103
|
+
const encryptedText = await fs.readFile(tokenPath, 'utf8');
|
|
104
|
+
const masterKey = await fs.readFile(masterKeyPath);
|
|
105
|
+
// Derive the extension's encryption key (scrypt with hostname-username salt).
|
|
106
|
+
// Must match `file-token-storage.ts` exactly.
|
|
107
|
+
const salt = `${os.hostname()}-${os.userInfo().username}${SALT_SUFFIX}`;
|
|
108
|
+
const encryptionKey = crypto.scryptSync(masterKey, salt, 32);
|
|
109
|
+
const decrypted = this.decryptExtensionFormat(encryptedText, encryptionKey);
|
|
110
|
+
const parsed = JSON.parse(decrypted);
|
|
111
|
+
const entry = parsed[MAIN_ACCOUNT_KEY];
|
|
112
|
+
if (!entry) {
|
|
113
|
+
throw new Error(`Extension token file does not contain a '${MAIN_ACCOUNT_KEY}' entry. ` +
|
|
114
|
+
`Did login complete successfully?`);
|
|
115
|
+
}
|
|
116
|
+
const { accessToken, refreshToken, tokenType, scope, expiresAt } = entry.token;
|
|
117
|
+
if (!accessToken || !refreshToken) {
|
|
118
|
+
throw new Error(`Extension token is missing accessToken or refreshToken — login may be incomplete.`);
|
|
119
|
+
}
|
|
120
|
+
const scopes = typeof scope === 'string' ? scope.split(' ').filter(Boolean) : [];
|
|
121
|
+
const accountEmail = await this.fetchUserEmail(accessToken);
|
|
122
|
+
return {
|
|
123
|
+
type: 'google-oauth',
|
|
124
|
+
accessToken,
|
|
125
|
+
refreshToken,
|
|
126
|
+
tokenType: tokenType ?? 'Bearer',
|
|
127
|
+
expiresAt: expiresAt ?? Date.now() + 3600_000,
|
|
128
|
+
scopes,
|
|
129
|
+
accountEmail,
|
|
130
|
+
clientId: this.clientId,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Remove the extension's token file. Call after `captureFromFile()` to
|
|
135
|
+
* prepare for the next account's login (the extension otherwise returns
|
|
136
|
+
* cached credentials if scopes match, skipping the login prompt).
|
|
137
|
+
* Leaves the master.key file untouched so existing ciphertexts remain
|
|
138
|
+
* decryptable if needed.
|
|
139
|
+
*/
|
|
140
|
+
async clearExtensionFile() {
|
|
141
|
+
const tokenPath = path.join(this.extensionPath, TOKEN_FILENAME);
|
|
142
|
+
try {
|
|
143
|
+
await fs.unlink(tokenPath);
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
if (err.code === 'ENOENT')
|
|
147
|
+
return;
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// ------------------------------------------------------------------
|
|
152
|
+
// Refresh / getAccessToken
|
|
153
|
+
// ------------------------------------------------------------------
|
|
154
|
+
/**
|
|
155
|
+
* Return a valid `GoogleOAuthPayload`, refreshing via the extension's
|
|
156
|
+
* cloud function if the access token is within the expiry buffer.
|
|
157
|
+
*
|
|
158
|
+
* Concurrent calls for the same credential share a single refresh.
|
|
159
|
+
*
|
|
160
|
+
* @throws CredentialRevokedError if the refresh token is no longer valid
|
|
161
|
+
*/
|
|
162
|
+
async getAccessToken(entry, payload) {
|
|
163
|
+
// Fast path: still valid
|
|
164
|
+
if (payload.expiresAt - Date.now() > this.expiryBufferMs) {
|
|
165
|
+
return payload;
|
|
166
|
+
}
|
|
167
|
+
// Coalesce concurrent refreshes for the same credential
|
|
168
|
+
const existing = this.refreshInFlight.get(entry.id);
|
|
169
|
+
if (existing)
|
|
170
|
+
return existing;
|
|
171
|
+
// Cooldown — avoid hammering the refresh endpoint
|
|
172
|
+
const last = this.lastRefreshAttempt.get(entry.id) ?? 0;
|
|
173
|
+
if (Date.now() - last < REFRESH_COOLDOWN_MS) {
|
|
174
|
+
// Within cooldown — return the (possibly expired) payload; caller will
|
|
175
|
+
// get a fresh attempt after the cooldown window.
|
|
176
|
+
return payload;
|
|
177
|
+
}
|
|
178
|
+
const promise = this.doRefresh(entry, payload);
|
|
179
|
+
this.refreshInFlight.set(entry.id, promise);
|
|
180
|
+
this.lastRefreshAttempt.set(entry.id, Date.now());
|
|
181
|
+
try {
|
|
182
|
+
return await promise;
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
this.refreshInFlight.delete(entry.id);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
async doRefresh(entry, payload) {
|
|
189
|
+
const url = this.cloudFunctionUrl.replace(/\/$/, '') + this.refreshPath;
|
|
190
|
+
const response = await this.fetchFn(url, {
|
|
191
|
+
method: 'POST',
|
|
192
|
+
headers: { 'Content-Type': 'application/json' },
|
|
193
|
+
body: JSON.stringify({ refresh_token: payload.refreshToken }),
|
|
194
|
+
});
|
|
195
|
+
if (!response.ok) {
|
|
196
|
+
const body = await response.text().catch(() => '');
|
|
197
|
+
// Google responds 400 with body containing "invalid_grant" when the
|
|
198
|
+
// refresh token is revoked/expired beyond recovery.
|
|
199
|
+
if (response.status === 400 &&
|
|
200
|
+
/invalid_grant/i.test(body)) {
|
|
201
|
+
await this.store.updateCredential(entry.id, { status: 'revoked' });
|
|
202
|
+
throw new CredentialRevokedError(entry.id, entry.name, `Log in to the Gemini CLI Workspace extension again and re-import this credential in Crewly.`);
|
|
203
|
+
}
|
|
204
|
+
throw new Error(`Token refresh failed (${response.status}): ${body.slice(0, 500)}`);
|
|
205
|
+
}
|
|
206
|
+
const data = (await response.json());
|
|
207
|
+
if (!data.access_token) {
|
|
208
|
+
throw new Error('Token refresh response missing access_token');
|
|
209
|
+
}
|
|
210
|
+
const expiresAt = Date.now() + (data.expires_in ?? 3600) * 1000;
|
|
211
|
+
const updated = {
|
|
212
|
+
...payload,
|
|
213
|
+
accessToken: data.access_token,
|
|
214
|
+
expiresAt,
|
|
215
|
+
// Google typically does NOT issue a new refresh_token on refresh,
|
|
216
|
+
// but honor it if present
|
|
217
|
+
refreshToken: data.refresh_token ?? payload.refreshToken,
|
|
218
|
+
scopes: typeof data.scope === 'string'
|
|
219
|
+
? data.scope.split(' ').filter(Boolean)
|
|
220
|
+
: payload.scopes,
|
|
221
|
+
};
|
|
222
|
+
await this.store.setPayload(entry.id, updated);
|
|
223
|
+
await this.store.updateCredential(entry.id, {
|
|
224
|
+
expiresAt,
|
|
225
|
+
lastUsedAt: new Date().toISOString(),
|
|
226
|
+
});
|
|
227
|
+
return updated;
|
|
228
|
+
}
|
|
229
|
+
// ------------------------------------------------------------------
|
|
230
|
+
// Internals
|
|
231
|
+
// ------------------------------------------------------------------
|
|
232
|
+
/**
|
|
233
|
+
* Decrypt the extension's `{iv_hex}:{authTag_hex}:{ciphertext_hex}`
|
|
234
|
+
* format (AES-256-GCM).
|
|
235
|
+
*/
|
|
236
|
+
decryptExtensionFormat(encryptedText, key) {
|
|
237
|
+
const parts = encryptedText.split(':');
|
|
238
|
+
if (parts.length !== 3) {
|
|
239
|
+
throw new Error('Invalid extension token format (expected iv:tag:ciphertext)');
|
|
240
|
+
}
|
|
241
|
+
const iv = Buffer.from(parts[0], 'hex');
|
|
242
|
+
const authTag = Buffer.from(parts[1], 'hex');
|
|
243
|
+
const ciphertextHex = parts[2];
|
|
244
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
245
|
+
decipher.setAuthTag(authTag);
|
|
246
|
+
let decrypted = decipher.update(ciphertextHex, 'hex', 'utf8');
|
|
247
|
+
decrypted += decipher.final('utf8');
|
|
248
|
+
return decrypted;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Fetch the user's email via Google's userinfo endpoint. Best-effort;
|
|
252
|
+
* returns undefined on failure.
|
|
253
|
+
*/
|
|
254
|
+
async fetchUserEmail(accessToken) {
|
|
255
|
+
try {
|
|
256
|
+
const response = await this.fetchFn(USERINFO_ENDPOINT, {
|
|
257
|
+
method: 'GET',
|
|
258
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
259
|
+
});
|
|
260
|
+
if (!response.ok)
|
|
261
|
+
return undefined;
|
|
262
|
+
const data = (await response.json());
|
|
263
|
+
return data.email;
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// ============================================================================
|
|
271
|
+
// Test helpers (not exported from public barrel — intended for tests only)
|
|
272
|
+
// ============================================================================
|
|
273
|
+
/**
|
|
274
|
+
* Encrypt a plaintext string with the extension's file format. Used in
|
|
275
|
+
* tests to generate fake token files.
|
|
276
|
+
*/
|
|
277
|
+
export function encryptExtensionFormatForTesting(plaintext, key) {
|
|
278
|
+
const iv = crypto.randomBytes(16);
|
|
279
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
280
|
+
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
|
|
281
|
+
encrypted += cipher.final('hex');
|
|
282
|
+
const authTag = cipher.getAuthTag();
|
|
283
|
+
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Derive the extension's encryption key using the real scrypt + salt.
|
|
287
|
+
* Test-only export.
|
|
288
|
+
*/
|
|
289
|
+
export function deriveExtensionKeyForTesting(masterKey) {
|
|
290
|
+
const salt = `${os.hostname()}-${os.userInfo().username}${SALT_SUFFIX}`;
|
|
291
|
+
return crypto.scryptSync(masterKey, salt, 32);
|
|
292
|
+
}
|
|
293
|
+
//# sourceMappingURL=gemini-cli-workspace.helper.js.map
|
package/dist/backend/backend/src/services/credential/helpers/gemini-cli-workspace.helper.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gemini-cli-workspace.helper.js","sourceRoot":"","sources":["../../../../../../../backend/src/services/credential/helpers/gemini-cli-workspace.helper.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
+
export declare const TASK_STATUSES: readonly ["open", "in_progress", "review", "done", "blocked"];
|
|
2
|
+
export type TaskStatus = typeof TASK_STATUSES[number];
|
|
3
|
+
export declare const TASK_PRIORITIES: readonly ["low", "medium", "high", "critical"];
|
|
4
|
+
export type TaskPriority = typeof TASK_PRIORITIES[number];
|
|
5
|
+
export declare function isValidTaskStatus(value: string): value is TaskStatus;
|
|
6
|
+
export declare function isValidTaskPriority(value: string): value is TaskPriority;
|
|
1
7
|
export interface Task {
|
|
2
8
|
id: string;
|
|
3
9
|
title: string;
|
|
4
10
|
description: string;
|
|
5
|
-
status:
|
|
6
|
-
priority:
|
|
11
|
+
status: TaskStatus;
|
|
12
|
+
priority: TaskPriority;
|
|
7
13
|
assignee?: string;
|
|
8
14
|
milestone: string;
|
|
9
15
|
milestoneId: string;
|
|
@@ -23,6 +29,16 @@ export declare class TaskService {
|
|
|
23
29
|
private tasksDir;
|
|
24
30
|
constructor(projectPath?: string);
|
|
25
31
|
private parseMarkdownContent;
|
|
32
|
+
/**
|
|
33
|
+
* Read one task markdown file and build a {@link Task}. Returns `null`
|
|
34
|
+
* when the file can't be read (corrupt, permission denied, missing).
|
|
35
|
+
*
|
|
36
|
+
* Status resolution falls back to `statusOverride` when the file's own
|
|
37
|
+
* `status:` front-matter is missing or invalid — this lets callers
|
|
38
|
+
* encode status in the folder layout (`milestone/open/foo.md`) without
|
|
39
|
+
* losing the type narrowing.
|
|
40
|
+
*/
|
|
41
|
+
private readTaskFile;
|
|
26
42
|
getAllTasks(): Promise<Task[]>;
|
|
27
43
|
getMilestones(): Promise<Milestone[]>;
|
|
28
44
|
getTasksByStatus(status: string): Promise<Task[]>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"task.service.d.ts","sourceRoot":"","sources":["../../../../../../backend/src/services/project/task.service.ts"],"names":[],"mappings":"AAIA,MAAM,
|
|
1
|
+
{"version":3,"file":"task.service.d.ts","sourceRoot":"","sources":["../../../../../../backend/src/services/project/task.service.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,aAAa,+DAAgE,CAAC;AAC3F,MAAM,MAAM,UAAU,GAAG,OAAO,aAAa,CAAC,MAAM,CAAC,CAAC;AAEtD,eAAO,MAAM,eAAe,gDAAiD,CAAC;AAC9E,MAAM,MAAM,YAAY,GAAG,OAAO,eAAe,CAAC,MAAM,CAAC,CAAC;AAK1D,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,KAAK,IAAI,UAAU,CAEpE;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,KAAK,IAAI,YAAY,CAExE;AAED,MAAM,WAAW,IAAI;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,UAAU,CAAC;IACnB,QAAQ,EAAE,YAAY,CAAC;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,kBAAkB,EAAE,MAAM,EAAE,CAAC;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,SAAS;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,IAAI,EAAE,CAAC;CACf;AAED,qBAAa,WAAW;IACtB,OAAO,CAAC,QAAQ,CAAS;gBAEb,WAAW,CAAC,EAAE,MAAM;IAMhC,OAAO,CAAC,oBAAoB;IA+F5B;;;;;;;;OAQG;YACW,YAAY;IA6CpB,WAAW,IAAI,OAAO,CAAC,IAAI,EAAE,CAAC;IA4E9B,aAAa,IAAI,OAAO,CAAC,SAAS,EAAE,CAAC;IAmBrC,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;IAKjD,mBAAmB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;CAIhE"}
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import * as fs from 'fs/promises';
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import { existsSync } from 'fs';
|
|
4
|
+
export const TASK_STATUSES = ['open', 'in_progress', 'review', 'done', 'blocked'];
|
|
5
|
+
export const TASK_PRIORITIES = ['low', 'medium', 'high', 'critical'];
|
|
6
|
+
/** Folder names that indicate a status-partitioned milestone directory. */
|
|
7
|
+
const STATUS_FOLDERS = ['open', 'in_progress', 'done', 'blocked'];
|
|
8
|
+
export function isValidTaskStatus(value) {
|
|
9
|
+
return TASK_STATUSES.includes(value);
|
|
10
|
+
}
|
|
11
|
+
export function isValidTaskPriority(value) {
|
|
12
|
+
return TASK_PRIORITIES.includes(value);
|
|
13
|
+
}
|
|
4
14
|
export class TaskService {
|
|
5
15
|
tasksDir;
|
|
6
16
|
constructor(projectPath) {
|
|
@@ -11,8 +21,12 @@ export class TaskService {
|
|
|
11
21
|
parseMarkdownContent(content) {
|
|
12
22
|
const lines = content.split('\n');
|
|
13
23
|
let title = '';
|
|
14
|
-
|
|
15
|
-
|
|
24
|
+
// Use empty string as the "unset" sentinel — readTaskFile narrows this
|
|
25
|
+
// through `isValidTaskStatus`/`isValidTaskPriority` and applies the real
|
|
26
|
+
// defaults. A non-empty sentinel like 'pending' here would be misleading
|
|
27
|
+
// because 'pending' is not a valid TaskStatus.
|
|
28
|
+
let status = '';
|
|
29
|
+
let priority = '';
|
|
16
30
|
let assignee = '';
|
|
17
31
|
let milestone = '';
|
|
18
32
|
let description = '';
|
|
@@ -82,6 +96,53 @@ export class TaskService {
|
|
|
82
96
|
acceptanceCriteria
|
|
83
97
|
};
|
|
84
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Read one task markdown file and build a {@link Task}. Returns `null`
|
|
101
|
+
* when the file can't be read (corrupt, permission denied, missing).
|
|
102
|
+
*
|
|
103
|
+
* Status resolution falls back to `statusOverride` when the file's own
|
|
104
|
+
* `status:` front-matter is missing or invalid — this lets callers
|
|
105
|
+
* encode status in the folder layout (`milestone/open/foo.md`) without
|
|
106
|
+
* losing the type narrowing.
|
|
107
|
+
*/
|
|
108
|
+
async readTaskFile(filePath, milestoneId, statusOverride) {
|
|
109
|
+
try {
|
|
110
|
+
const [content, stats] = await Promise.all([
|
|
111
|
+
fs.readFile(filePath, 'utf-8'),
|
|
112
|
+
fs.stat(filePath),
|
|
113
|
+
]);
|
|
114
|
+
const parsed = this.parseMarkdownContent(content);
|
|
115
|
+
const fileBase = path.basename(filePath, '.md');
|
|
116
|
+
const resolvedStatus = statusOverride
|
|
117
|
+
?? (isValidTaskStatus(parsed.status) ? parsed.status : 'open');
|
|
118
|
+
const resolvedPriority = isValidTaskPriority(parsed.priority)
|
|
119
|
+
? parsed.priority
|
|
120
|
+
: 'medium';
|
|
121
|
+
// Real filesystem timestamps so consumers that sort/cache by
|
|
122
|
+
// createdAt/updatedAt get stable values across reads. birthtimeMs
|
|
123
|
+
// falls back to mtime on filesystems that don't track creation time.
|
|
124
|
+
const createdAt = new Date(stats.birthtimeMs || stats.mtimeMs).toISOString();
|
|
125
|
+
const updatedAt = new Date(stats.mtimeMs).toISOString();
|
|
126
|
+
return {
|
|
127
|
+
id: fileBase,
|
|
128
|
+
title: parsed.title || fileBase.replace(/_/g, ' '),
|
|
129
|
+
description: parsed.description,
|
|
130
|
+
status: resolvedStatus,
|
|
131
|
+
priority: resolvedPriority,
|
|
132
|
+
assignee: parsed.assignee,
|
|
133
|
+
milestone: parsed.milestone || milestoneId,
|
|
134
|
+
milestoneId,
|
|
135
|
+
tasks: parsed.tasks,
|
|
136
|
+
acceptanceCriteria: parsed.acceptanceCriteria,
|
|
137
|
+
filePath,
|
|
138
|
+
createdAt,
|
|
139
|
+
updatedAt,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
85
146
|
async getAllTasks() {
|
|
86
147
|
if (!existsSync(this.tasksDir)) {
|
|
87
148
|
return [];
|
|
@@ -112,11 +173,10 @@ export class TaskService {
|
|
|
112
173
|
catch {
|
|
113
174
|
continue;
|
|
114
175
|
}
|
|
115
|
-
const
|
|
116
|
-
const hasStatusFolders = items.some(item => statusFolders.includes(item));
|
|
176
|
+
const hasStatusFolders = items.some(item => STATUS_FOLDERS.includes(item));
|
|
117
177
|
if (hasStatusFolders) {
|
|
118
178
|
// Status-based structure: milestone/status/task.md
|
|
119
|
-
for (const statusFolder of
|
|
179
|
+
for (const statusFolder of STATUS_FOLDERS) {
|
|
120
180
|
const statusPath = path.join(milestonePath, statusFolder);
|
|
121
181
|
if (!existsSync(statusPath))
|
|
122
182
|
continue;
|
|
@@ -137,31 +197,9 @@ export class TaskService {
|
|
|
137
197
|
}
|
|
138
198
|
const markdownFiles = statusFiles.filter(file => file.endsWith('.md'));
|
|
139
199
|
for (const file of markdownFiles) {
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
const content = await fs.readFile(filePath, 'utf-8');
|
|
143
|
-
const parsed = this.parseMarkdownContent(content);
|
|
144
|
-
const task = {
|
|
145
|
-
id: path.basename(file, '.md'),
|
|
146
|
-
title: parsed.title || path.basename(file, '.md').replace(/_/g, ' '),
|
|
147
|
-
description: parsed.description,
|
|
148
|
-
status: statusFolder, // Use folder name as status
|
|
149
|
-
priority: parsed.priority,
|
|
150
|
-
assignee: parsed.assignee,
|
|
151
|
-
milestone: parsed.milestone || milestone,
|
|
152
|
-
milestoneId: milestone,
|
|
153
|
-
tasks: parsed.tasks,
|
|
154
|
-
acceptanceCriteria: parsed.acceptanceCriteria,
|
|
155
|
-
filePath,
|
|
156
|
-
createdAt: new Date().toISOString(),
|
|
157
|
-
updatedAt: new Date().toISOString()
|
|
158
|
-
};
|
|
200
|
+
const task = await this.readTaskFile(path.join(statusPath, file), milestone, statusFolder);
|
|
201
|
+
if (task)
|
|
159
202
|
tasks.push(task);
|
|
160
|
-
}
|
|
161
|
-
catch {
|
|
162
|
-
// Skip files that cannot be read (corrupt, permission denied, etc.)
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
203
|
}
|
|
166
204
|
}
|
|
167
205
|
}
|
|
@@ -169,31 +207,9 @@ export class TaskService {
|
|
|
169
207
|
// Direct structure: milestone/task.md
|
|
170
208
|
const markdownFiles = items.filter(file => file.endsWith('.md'));
|
|
171
209
|
for (const file of markdownFiles) {
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
const content = await fs.readFile(filePath, 'utf-8');
|
|
175
|
-
const parsed = this.parseMarkdownContent(content);
|
|
176
|
-
const task = {
|
|
177
|
-
id: path.basename(file, '.md'),
|
|
178
|
-
title: parsed.title,
|
|
179
|
-
description: parsed.description,
|
|
180
|
-
status: parsed.status,
|
|
181
|
-
priority: parsed.priority,
|
|
182
|
-
assignee: parsed.assignee,
|
|
183
|
-
milestone: parsed.milestone,
|
|
184
|
-
milestoneId: milestone,
|
|
185
|
-
tasks: parsed.tasks,
|
|
186
|
-
acceptanceCriteria: parsed.acceptanceCriteria,
|
|
187
|
-
filePath,
|
|
188
|
-
createdAt: new Date().toISOString(),
|
|
189
|
-
updatedAt: new Date().toISOString()
|
|
190
|
-
};
|
|
210
|
+
const task = await this.readTaskFile(path.join(milestonePath, file), milestone);
|
|
211
|
+
if (task)
|
|
191
212
|
tasks.push(task);
|
|
192
|
-
}
|
|
193
|
-
catch {
|
|
194
|
-
// Skip files that cannot be read
|
|
195
|
-
continue;
|
|
196
|
-
}
|
|
197
213
|
}
|
|
198
214
|
}
|
|
199
215
|
}
|